From ec197b2e133594013ad18efb47dafeba1b07f4ea Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 19 Aug 2024 08:17:54 +0000 Subject: [PATCH 001/539] memorystore: simplify, refactor, re-enable Removes a lot of unused (in ABS) functionality, refactors to ES6 style class, and re-enables this custom implementation with check period and ttl of 1 day, and 1000 max entries. The class now only implments the required (as per express-session docs) methods and removes optional methods, except touch() which allows the TTL of an entry to be refreshed without affecting its LRU recency. There is no longer a way to stop the prune timer, but I don't belive the function was ever being called beforehand. The session store's lifetime is the same as the application's, and since it is unref()'d should not cause any shutdown issues. --- server/Server.js | 4 +- server/libs/memorystore/index.js | 295 ++++++------------------------- 2 files changed, 58 insertions(+), 241 deletions(-) diff --git a/server/Server.js b/server/Server.js index 0110ab6a70..c110aa2f78 100644 --- a/server/Server.js +++ b/server/Server.js @@ -41,6 +41,7 @@ const LibraryScanner = require('./scanner/LibraryScanner') //Import the main Passport and Express-Session library const passport = require('passport') const expressSession = require('express-session') +const MemoryStore = require('./libs/memorystore') class Server { constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { @@ -232,7 +233,8 @@ class Server { cookie: { // also send the cookie if were are not on https (not every use has https) secure: false - } + }, + store: new MemoryStore(86400000, 86400000, 1000) }) ) // init passport.js diff --git a/server/libs/memorystore/index.js b/server/libs/memorystore/index.js index b17e881355..06fd37fb93 100644 --- a/server/libs/memorystore/index.js +++ b/server/libs/memorystore/index.js @@ -8,89 +8,33 @@ // SOURCE: https://github.com/roccomuso/memorystore // -var debug = require('debug')('memorystore') +const debug = require('debug')('memorystore') const { LRUCache } = require('lru-cache') -var util = require('util') +const { Store } = require('express-session') /** - * One day in milliseconds. - */ - -var oneDay = 86400000 - -function getTTL(options, sess, sid) { - if (typeof options.ttl === 'number') return options.ttl - if (typeof options.ttl === 'function') return options.ttl(options, sess, sid) - if (options.ttl) throw new TypeError('`options.ttl` must be a number or function.') - - var maxAge = sess?.cookie?.maxAge || null - return typeof maxAge === 'number' ? Math.floor(maxAge) : oneDay -} - -function prune(store) { - debug('Pruning expired entries') - store.forEach(function (value, key) { - store.get(key) - }) -} - -var defer = - typeof setImmediate === 'function' - ? setImmediate - : function (fn) { - process.nextTick(fn.bind.apply(fn, arguments)) - } - -/** - * Return the `MemoryStore` extending `express`'s session Store. + * An alternative memory store implementation for express session that prunes stale entries. * - * @param {object} express session - * @return {Function} - * @api public + * @param {number} checkPeriod stale entry pruning frequency in ms + * @param {number} ttl entry time to live in ms + * @param {number} max LRU cache max entries */ - -module.exports = function (session) { - /** - * Express's session Store. - */ - - var Store = session.Store - - /** - * Initialize MemoryStore with the given `options`. - * - * @param {Object} options - * @api public - */ - - function MemoryStore(options) { - if (!(this instanceof MemoryStore)) { - throw new TypeError('Cannot call MemoryStore constructor as a function') +module.exports = class MemoryStore extends Store { + constructor(checkPeriod, ttl, max) { + if (typeof checkPeriod !== 'number' || typeof checkPeriod !== 'number' || typeof checkPeriod !== 'number') { + throw Error('All arguments must be provided') } - - options = options || {} - Store.call(this, options) - - this.options = {} - this.options.checkPeriod = options.checkPeriod - this.options.max = options.max - this.options.ttl = options.ttl - this.options.dispose = options.dispose - this.options.stale = options.stale - - this.serializer = options.serializer || JSON - this.store = new LRUCache(this.options) - debug('Init MemoryStore') - - this.startInterval() + super() + this.store = new LRUCache({ ttl, max }) + let prune = () => { + let sizeBefore = this.store.size + this.store.purgeStale() + debug('PRUNE size changed by %i entries', sizeBefore - this.store.size) + } + setInterval(prune, Math.floor(checkPeriod)).unref() + debug('INIT MemoryStore constructed with checkPeriod "%i", ttl "%i", max "%i"', checkPeriod, ttl, max) } - /** - * Inherit from `Store`. - */ - - util.inherits(MemoryStore, Store) - /** * Attempt to fetch session by the given `sid`. * @@ -98,25 +42,19 @@ module.exports = function (session) { * @param {Function} fn * @api public */ - - MemoryStore.prototype.get = function (sid, fn) { - var store = this.store - - debug('GET "%s"', sid) - - var data = store.get(sid) - if (!data) return fn() - - debug('GOT %s', data) - var err = null - var result - try { - result = this.serializer.parse(data) - } catch (er) { - err = er + get(sid, fn) { + let err = null + let res = null + const data = this.store.get(sid) + debug('GET %s: %s', sid, data) + if (data) { + try { + res = JSON.parse(data) + } catch (e) { + err = e + } } - - fn && defer(fn, err, result) + fn && setImmediate(fn, err, res) } /** @@ -127,48 +65,39 @@ module.exports = function (session) { * @param {Function} fn * @api public */ - - MemoryStore.prototype.set = function (sid, sess, fn) { - var store = this.store - - var ttl = getTTL(this.options, sess, sid) + set(sid, sess, fn) { + let err = null try { - var jsess = this.serializer.stringify(sess) - } catch (err) { - fn && defer(fn, err) + let jsess = JSON.stringify(sess) + debug('SET %s: %s', sid, jsess) + this.store.set(sid, jsess) + } catch (e) { + err = e } - - store.set(sid, jsess, { - ttl - }) - debug('SET "%s" %s ttl:%s', sid, jsess, ttl) - fn && defer(fn, null) + fn && setImmediate(fn, err) } /** * Destroy the session associated with the given `sid`. * * @param {String} sid + * @param {Function} fn * @api public */ - - MemoryStore.prototype.destroy = function (sid, fn) { - var store = this.store - - if (Array.isArray(sid)) { - sid.forEach(function (s) { - debug('DEL "%s"', s) - store.delete(s) - }) - } else { - debug('DEL "%s"', sid) - store.delete(sid) + destroy(sid, fn) { + debug('DESTROY %s', sid) + let err = null + try { + this.store.delete(sid) + } catch (e) { + err = e } - fn && defer(fn, null) + fn && setImmediate(fn, err) } /** - * Refresh the time-to-live for the session with the given `sid`. + * Refresh the time-to-live for the session with the given `sid` without affecting + * LRU recency. * * @param {String} sid * @param {Session} sess @@ -176,128 +105,14 @@ module.exports = function (session) { * @api public */ - MemoryStore.prototype.touch = function (sid, sess, fn) { - var store = this.store - - var ttl = getTTL(this.options, sess, sid) - - debug('EXPIRE "%s" ttl:%s', sid, ttl) - var err = null - if (store.get(sid) !== undefined) { - try { - var s = this.serializer.parse(store.get(sid)) - s.cookie = sess.cookie - store.set(sid, this.serializer.stringify(s), { - ttl - }) - } catch (e) { - err = e - } - } - fn && defer(fn, err) - } - - /** - * Fetch all sessions' ids - * - * @param {Function} fn - * @api public - */ - - MemoryStore.prototype.ids = function (fn) { - var store = this.store - - var Ids = store.keys() - debug('Getting IDs: %s', Ids) - fn && defer(fn, null, Ids) - } - - /** - * Fetch all sessions - * - * @param {Function} fn - * @api public - */ - - MemoryStore.prototype.all = function (fn) { - var store = this.store - var self = this - - debug('Fetching all sessions') - var err = null - var result = {} + touch(sid, sess, fn) { + debug('TOUCH %s', sid) + let err = null try { - store.forEach(function (val, key) { - result[key] = self.serializer.parse(val) - }) + this.store.has(sid, { updateAgeOnHas: true }) } catch (e) { err = e } - fn && defer(fn, err, result) - } - - /** - * Delete all sessions from the store - * - * @param {Function} fn - * @api public - */ - - MemoryStore.prototype.clear = function (fn) { - var store = this.store - debug('delete all sessions from the store') - store.clear() - fn && defer(fn, null) - } - - /** - * Get the count of all sessions in the store - * - * @param {Function} fn - * @api public - */ - - MemoryStore.prototype.length = function (fn) { - var store = this.store - debug('getting length', store.size) - fn && defer(fn, null, store.size) - } - - /** - * Start the check interval - * @api public - */ - - MemoryStore.prototype.startInterval = function () { - var self = this - var ms = this.options.checkPeriod - if (ms && typeof ms === 'number') { - clearInterval(this._checkInterval) - debug('starting periodic check for expired sessions') - this._checkInterval = setInterval(function () { - prune(self.store) // iterates over the entire cache proactively pruning old entries - }, Math.floor(ms)).unref() - } - } - - /** - * Stop the check interval - * @api public - */ - - MemoryStore.prototype.stopInterval = function () { - debug('stopping periodic check for expired sessions') - clearInterval(this._checkInterval) + fn && setImmediate(fn, err) } - - /** - * Remove only expired entries from the store - * @api public - */ - - MemoryStore.prototype.prune = function () { - prune(this.store) - } - - return MemoryStore } From 5d13faef33898d2768e82e2f4746e66c2867810a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 24 Aug 2024 15:38:15 -0500 Subject: [PATCH 002/539] Updates to LibraryController to use new Library model - Additional validation on API endpoints - Removed success toast when reorder libraries --- .../tables/library/LibrariesTable.vue | 5 +- server/Watcher.js | 3 +- server/controllers/LibraryController.js | 270 +++++++++++++----- server/managers/CronManager.js | 6 +- server/models/Library.js | 29 ++ server/models/LibraryFolder.js | 12 + server/objects/Library.js | 63 ---- server/routers/ApiRouter.js | 1 + server/utils/migrations/dbMigration.js | 2 +- server/utils/queries/libraryItemFilters.js | 10 +- .../utils/queries/libraryItemsBookFilters.js | 16 +- .../queries/libraryItemsPodcastFilters.js | 10 +- 12 files changed, 259 insertions(+), 168 deletions(-) diff --git a/client/components/tables/library/LibrariesTable.vue b/client/components/tables/library/LibrariesTable.vue index faf8d69d27..62b1858efd 100644 --- a/client/components/tables/library/LibrariesTable.vue +++ b/client/components/tables/library/LibrariesTable.vue @@ -76,8 +76,7 @@ export default { var newOrder = libraryOrderData.map((lib) => lib.id).join(',') if (currOrder !== newOrder) { this.$axios.$post('/api/libraries/order', libraryOrderData).then((response) => { - if (response.libraries && response.libraries.length) { - this.$toast.success('Library order saved', { timeout: 1500 }) + if (response.libraries?.length) { this.$store.commit('libraries/set', response.libraries) } }) @@ -110,4 +109,4 @@ export default { this.$store.commit('libraries/removeListener', 'libraries-table') } } - \ No newline at end of file + diff --git a/server/Watcher.js b/server/Watcher.js index 999437d805..32788e27db 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -122,9 +122,8 @@ class FolderWatcher extends EventEmitter { } /** - * TODO: Update to new library model * - * @param {import('./objects/Library')} library + * @param {import('./models/Library')} library */ updateLibrary(library) { if (this.disabled) return diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 13b82ad402..cc9e6e2e55 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -27,6 +27,11 @@ const authorFilters = require('../utils/queries/authorFilters') * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/Library')} library + * + * @typedef {RequestWithUser & RequestEntityObject} LibraryControllerRequest */ class LibraryController { @@ -147,21 +152,25 @@ class LibraryController { library.libraryFolders = await library.getLibraryFolders() - // TODO: Migrate to new library model - const oldLibrary = Database.libraryModel.getOldLibrary(library) - // Only emit to users with access to library const userFilter = (user) => { - return user.checkCanAccessLibrary?.(oldLibrary.id) + return user.checkCanAccessLibrary?.(library.id) } - SocketAuthority.emitter('library_added', oldLibrary.toJSON(), userFilter) + SocketAuthority.emitter('library_added', library.toOldJSON(), userFilter) // Add library watcher this.watcher.addLibrary(library) - res.json(oldLibrary) + res.json(library.toOldJSON()) } + /** + * GET: /api/libraries + * Get all libraries + * + * @param {RequestWithUser} req + * @param {Response} res + */ async findAll(req, res) { const libraries = await Database.libraryModel.getAllOldLibraries() @@ -180,7 +189,7 @@ class LibraryController { /** * GET: /api/libraries/:id * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async findOne(req, res) { @@ -204,7 +213,8 @@ class LibraryController { /** * GET: /api/libraries/:id/episode-downloads * Get podcast episodes in download queue - * @param {RequestWithUser} req + * + * @param {LibraryControllerRequest} req * @param {Response} res */ async getEpisodeDownloadQueue(req, res) { @@ -215,12 +225,28 @@ class LibraryController { /** * PATCH: /api/libraries/:id * - * @param {RequestWithUser} req + * @this {import('../routers/ApiRouter')} + * + * @param {LibraryControllerRequest} req * @param {Response} res */ async update(req, res) { - /** @type {import('../objects/Library')} */ - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) + // Validation + const updatePayload = {} + const keysToCheck = ['name', 'provider', 'mediaType', 'icon'] + for (const key of keysToCheck) { + if (!req.body[key]) continue + if (typeof req.body[key] !== 'string') { + return res.status(400).send(`Invalid request. ${key} must be a string`) + } + updatePayload[key] = req.body[key] + } + if (req.body.displayOrder !== undefined) { + if (isNaN(req.body.displayOrder)) { + return res.status(400).send('Invalid request. displayOrder must be a number') + } + updatePayload.displayOrder = req.body.displayOrder + } // Validate that the custom provider exists if given any if (req.body.provider?.startsWith('custom-')) { @@ -230,21 +256,72 @@ class LibraryController { } } + // Validate settings + const updatedSettings = { + ...(req.library.settings || Database.libraryModel.getDefaultLibrarySettingsForMediaType(req.library.mediaType)) + } + let hasUpdates = false + let hasUpdatedDisableWatcher = false + let hasUpdatedScanCron = false + if (req.body.settings) { + for (const key in req.body.settings) { + if (updatedSettings[key] === undefined) continue + + if (key === 'metadataPrecedence') { + if (!Array.isArray(req.body.settings[key])) { + return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array') + } + if (JSON.stringify(req.body.settings[key]) !== JSON.stringify(updatedSettings[key])) { + hasUpdates = true + updatedSettings[key] = [...req.body.settings[key]] + Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`) + } + } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') { + if (req.body.settings[key] !== null && typeof req.body.settings[key] !== 'string') { + return res.status(400).send(`Invalid request. Settings "${key}" must be a string`) + } + if (req.body.settings[key] !== updatedSettings[key]) { + if (key === 'autoScanCronExpression') hasUpdatedScanCron = true + + hasUpdates = true + updatedSettings[key] = req.body.settings[key] + Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`) + } + } else { + if (typeof req.body.settings[key] !== typeof updatedSettings[key]) { + return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`) + } + if (req.body.settings[key] !== updatedSettings[key]) { + if (key === 'disableWatcher') hasUpdatedDisableWatcher = true + + hasUpdates = true + updatedSettings[key] = req.body.settings[key] + Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`) + } + } + } + if (hasUpdates) { + updatePayload.settings = updatedSettings + req.library.changed('settings', true) + } + } + + let hasFolderUpdates = false // Validate new folder paths exist or can be created & resolve rel paths // returns 400 if a new folder fails to access - if (req.body.folders) { + if (Array.isArray(req.body.folders)) { const newFolderPaths = [] req.body.folders = req.body.folders.map((f) => { if (!f.id) { - f.fullPath = fileUtils.filePathToPOSIX(Path.resolve(f.fullPath)) - newFolderPaths.push(f.fullPath) + const path = f.fullPath || f.path + f.path = fileUtils.filePathToPOSIX(Path.resolve(path)) + newFolderPaths.push(f.path) } return f }) for (const path of newFolderPaths) { const pathExists = await fs.pathExists(path) if (!pathExists) { - // Ensure dir will recursively create directories which might be preferred over mkdir const success = await fs .ensureDir(path) .then(() => true) @@ -256,10 +333,17 @@ class LibraryController { return res.status(400).send(`Invalid folder directory "${path}"`) } } + // Create folder + const libraryFolder = await Database.libraryFolderModel.create({ + path, + libraryId: req.library.id + }) + Logger.info(`[LibraryController] Created folder "${libraryFolder.path}" for library "${req.library.name}"`) + hasFolderUpdates = true } // Handle removing folders - for (const folder of oldLibrary.folders) { + for (const folder of req.library.libraryFolders) { if (!req.body.folders.some((f) => f.id === folder.id)) { // Remove library items in folder const libraryItemsInFolder = await Database.libraryItemModel.findAll({ @@ -278,67 +362,82 @@ class LibraryController { } ] }) - Logger.info(`[LibraryController] Removed folder "${folder.fullPath}" from library "${oldLibrary.name}" with ${libraryItemsInFolder.length} library items`) + Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`) for (const libraryItem of libraryItemsInFolder) { let mediaItemIds = [] - if (oldLibrary.isPodcast) { + if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) } - Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.fullPath}"`) + Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) } + + // Remove folder + await folder.destroy() + hasFolderUpdates = true } } } - const hasUpdates = oldLibrary.update(req.body) - // TODO: Should check if this is an update to folder paths or name only - if (hasUpdates) { + if (Object.keys(updatePayload).length) { + req.library.set(updatePayload) + if (req.library.changed()) { + Logger.debug(`[LibraryController] Updated library "${req.library.name}" with changed keys ${req.library.changed()}`) + hasUpdates = true + await req.library.save() + } + } + + if (hasUpdatedScanCron) { + Logger.debug(`[LibraryController] Updated library "${req.library.name}" auto scan cron`) // Update auto scan cron - this.cronManager.updateLibraryScanCron(oldLibrary) + this.cronManager.updateLibraryScanCron(req.library) + } - const updatedLibrary = await Database.libraryModel.updateFromOld(oldLibrary) - updatedLibrary.libraryFolders = await updatedLibrary.getLibraryFolders() + if (hasFolderUpdates || hasUpdatedDisableWatcher) { + req.library.libraryFolders = await req.library.getLibraryFolders() // Update watcher - this.watcher.updateLibrary(updatedLibrary) + this.watcher.updateLibrary(req.library) + hasUpdates = true + } + + if (hasUpdates) { // Only emit to users with access to library const userFilter = (user) => { - return user.checkCanAccessLibrary?.(oldLibrary.id) + return user.checkCanAccessLibrary?.(req.library.id) } - SocketAuthority.emitter('library_updated', oldLibrary.toJSON(), userFilter) + SocketAuthority.emitter('library_updated', req.library.toOldJSON(), userFilter) - await Database.resetLibraryIssuesFilterData(oldLibrary.id) + await Database.resetLibraryIssuesFilterData(req.library.id) } - return res.json(oldLibrary.toJSON()) + return res.json(req.library.toOldJSON()) } /** * DELETE: /api/libraries/:id * Delete a library * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async delete(req, res) { - const library = Database.libraryModel.getOldLibrary(req.library) - // Remove library watcher this.watcher.removeLibrary(req.library) // Remove collections for library - const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(library.id) + const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(req.library.id) if (numCollectionsRemoved) { - Logger.info(`[Server] Removed ${numCollectionsRemoved} collections for library "${library.name}"`) + Logger.info(`[Server] Removed ${numCollectionsRemoved} collections for library "${req.library.name}"`) } // Remove items in this library const libraryItemsInLibrary = await Database.libraryItemModel.findAll({ where: { - libraryId: library.id + libraryId: req.library.id }, attributes: ['id', 'mediaId', 'mediaType'], include: [ @@ -352,20 +451,20 @@ class LibraryController { } ] }) - Logger.info(`[LibraryController] Removing ${libraryItemsInLibrary.length} library items in library "${library.name}"`) + Logger.info(`[LibraryController] Removing ${libraryItemsInLibrary.length} library items in library "${req.library.name}"`) for (const libraryItem of libraryItemsInLibrary) { let mediaItemIds = [] - if (library.isPodcast) { + if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) } - Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${library.name}"`) + Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) } - const libraryJson = library.toJSON() - await Database.removeLibrary(library.id) + const libraryJson = req.library.toOldJSON() + await Database.removeLibrary(req.library.id) // Re-order libraries await Database.libraryModel.resetDisplayOrder() @@ -373,8 +472,8 @@ class LibraryController { SocketAuthority.emitter('library_removed', libraryJson) // Remove library filter data - if (Database.libraryFilterData[library.id]) { - delete Database.libraryFilterData[library.id] + if (Database.libraryFilterData[req.library.id]) { + delete Database.libraryFilterData[req.library.id] } return res.json(libraryJson) @@ -383,12 +482,10 @@ class LibraryController { /** * GET /api/libraries/:id/items * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getLibraryItems(req, res) { - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) - const include = (req.query.include || '') .split(',') .map((v) => v.trim().toLowerCase()) @@ -410,6 +507,8 @@ class LibraryController { payload.offset = payload.page * payload.limit // TODO: Temporary way of handling collapse sub-series. Either remove feature or handle through sql queries + // TODO: Update to new library model + const oldLibrary = Database.libraryModel.getOldLibrary(req.library) const filterByGroup = payload.filterBy?.split('.').shift() const filterByValue = filterByGroup ? libraryFilters.decode(payload.filterBy.replace(`${filterByGroup}.`, '')) : null if (filterByGroup === 'series' && filterByValue !== 'no-series' && payload.collapseseries) { @@ -425,9 +524,10 @@ class LibraryController { } /** - * DELETE: /libraries/:id/issues + * DELETE: /api/libraries/:id/issues * Remove all library items missing or invalid - * @param {RequestWithUser} req + * + * @param {LibraryControllerRequest} req * @param {Response} res */ async removeLibraryItemsWithIssues(req, res) { @@ -464,7 +564,7 @@ class LibraryController { Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) for (const libraryItem of libraryItemsWithIssues) { let mediaItemIds = [] - if (req.library.mediaType === 'podcast') { + if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) @@ -485,10 +585,11 @@ class LibraryController { * GET: /api/libraries/:id/series * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getAllSeriesForLibrary(req, res) { + // TODO: Update to new library model const oldLibrary = Database.libraryModel.getOldLibrary(req.library) const include = (req.query.include || '') @@ -523,7 +624,7 @@ class LibraryController { * rssfeed: adds `rssFeed` to series object if a feed is open * progress: adds `progress` to series object with { libraryItemIds:Array, libraryItemIdsFinished:Array, isFinished:boolean } * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res - Series */ async getSeriesForLibrary(req, res) { @@ -560,7 +661,7 @@ class LibraryController { * GET: /api/libraries/:id/collections * Get all collections for library * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getCollectionsForLibrary(req, res) { @@ -599,7 +700,7 @@ class LibraryController { * GET: /api/libraries/:id/playlists * Get playlists for user in library * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getUserPlaylistsForLibrary(req, res) { @@ -624,7 +725,7 @@ class LibraryController { /** * GET: /api/libraries/:id/filterdata * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getLibraryFilterData(req, res) { @@ -636,10 +737,11 @@ class LibraryController { * GET: /api/libraries/:id/personalized * Home page shelves * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getUserPersonalizedShelves(req, res) { + // TODO: Update to new library model const oldLibrary = Database.libraryModel.getOldLibrary(req.library) const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10 const include = (req.query.include || '') @@ -654,7 +756,13 @@ class LibraryController { * POST: /api/libraries/order * Change the display order of libraries * - * @param {RequestWithUser} req + * @typedef LibraryReorderObj + * @property {string} id + * @property {number} newOrder + * + * @typedef {Request<{}, {}, LibraryReorderObj[], {}> & RequestUserObject} LibraryReorderRequest + * + * @param {LibraryReorderRequest} req * @param {Response} res */ async reorder(req, res) { @@ -662,20 +770,25 @@ class LibraryController { Logger.error(`[LibraryController] Non-admin user "${req.user}" attempted to reorder libraries`) return res.sendStatus(403) } - const libraries = await Database.libraryModel.getAllOldLibraries() + + const libraries = await Database.libraryModel.getAllWithFolders() const orderdata = req.body + if (!Array.isArray(orderdata) || orderdata.some((o) => typeof o?.id !== 'string' || typeof o?.newOrder !== 'number')) { + return res.status(400).send('Invalid request. Request body must be an array of objects') + } + let hasUpdates = false for (let i = 0; i < orderdata.length; i++) { const library = libraries.find((lib) => lib.id === orderdata[i].id) if (!library) { Logger.error(`[LibraryController] Invalid library not found in reorder ${orderdata[i].id}`) - return res.sendStatus(500) - } - if (library.update({ displayOrder: orderdata[i].newOrder })) { - hasUpdates = true - await Database.libraryModel.updateFromOld(library) + return res.status(400).send(`Library not found with id ${orderdata[i].id}`) } + if (library.displayOrder === orderdata[i].newOrder) continue + library.displayOrder = orderdata[i].newOrder + await library.save() + hasUpdates = true } if (hasUpdates) { @@ -686,7 +799,7 @@ class LibraryController { } res.json({ - libraries: libraries.map((lib) => lib.toJSON()) + libraries: libraries.map((lib) => lib.toOldJSON()) }) } @@ -695,18 +808,18 @@ class LibraryController { * Search library items with query * * ?q=search - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async search(req, res) { if (!req.query.q || typeof req.query.q !== 'string') { return res.status(400).send('Invalid request. Query param "q" must be a string') } - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) + const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 const query = asciiOnlyToLowerCase(req.query.q.trim()) - const matches = await libraryItemFilters.search(req.user, oldLibrary, query, limit) + const matches = await libraryItemFilters.search(req.user, req.library, query, limit) res.json(matches) } @@ -714,7 +827,7 @@ class LibraryController { * GET: /api/libraries/:id/stats * Get stats for library * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async stats(req, res) { @@ -757,7 +870,7 @@ class LibraryController { * GET: /api/libraries/:id/authors * Get authors for library * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getAuthors(req, res) { @@ -796,7 +909,7 @@ class LibraryController { /** * GET: /api/libraries/:id/narrators * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getNarrators(req, res) { @@ -843,7 +956,7 @@ class LibraryController { * :narratorId is base64 encoded name * req.body { name } * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async updateNarrator(req, res) { @@ -894,7 +1007,7 @@ class LibraryController { * Remove narrator * :narratorId is base64 encoded name * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async removeNarrator(req, res) { @@ -937,7 +1050,7 @@ class LibraryController { * GET: /api/libraries/:id/matchall * Quick match all library items. Book libraries only. * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async matchAll(req, res) { @@ -945,6 +1058,7 @@ class LibraryController { Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`) return res.sendStatus(403) } + // TODO: Update to new library model const oldLibrary = Database.libraryModel.getOldLibrary(req.library) Scanner.matchLibraryItems(oldLibrary) res.sendStatus(200) @@ -955,7 +1069,7 @@ class LibraryController { * Optional query: * ?force=1 * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async scan(req, res) { @@ -964,6 +1078,7 @@ class LibraryController { return res.sendStatus(403) } res.sendStatus(200) + // TODO: Update to new library model const oldLibrary = Database.libraryModel.getOldLibrary(req.library) const forceRescan = req.query.force === '1' await LibraryScanner.scan(oldLibrary, forceRescan) @@ -976,13 +1091,14 @@ class LibraryController { * GET: /api/libraries/:id/recent-episodes * Used for latest page * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getRecentEpisodes(req, res) { if (req.library.mediaType !== 'podcast') { return res.sendStatus(404) } + // TODO: Update to new library model const oldLibrary = Database.libraryModel.getOldLibrary(req.library) const payload = { episodes: [], @@ -999,7 +1115,7 @@ class LibraryController { * GET: /api/libraries/:id/opml * Get OPML file for a podcast library * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async getOPMLFile(req, res) { @@ -1023,9 +1139,10 @@ class LibraryController { } /** + * POST: /api/libraries/:id/remove-metadata * Remove all metadata.json or metadata.abs files in library item folders * - * @param {RequestWithUser} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async removeAllMetadataFiles(req, res) { @@ -1084,7 +1201,6 @@ class LibraryController { return res.sendStatus(403) } - // const library = await Database.libraryModel.getOldById(req.params.id) const library = await Database.libraryModel.findByIdWithFolders(req.params.id) if (!library) { return res.status(404).send('Library not found') diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index 911d6480c1..09ff4d95e3 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -81,9 +81,8 @@ class CronManager { } /** - * TODO: Update to new library model * - * @param {*} library + * @param {import('../models/Library')} library */ removeCronForLibrary(library) { Logger.debug(`[CronManager] Removing library scan cron for ${library.name}`) @@ -91,9 +90,8 @@ class CronManager { } /** - * TODO: Update to new library model * - * @param {*} library + * @param {import('../models/Library')} library */ updateLibraryScanCron(library) { const expression = library.settings.autoScanCronExpression diff --git a/server/models/Library.js b/server/models/Library.js index 9aa6016bf6..3ebd32df91 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -301,6 +301,35 @@ class Library extends Model { } ) } + + get isPodcast() { + return this.mediaType === 'podcast' + } + get isBook() { + return this.mediaType === 'book' + } + + /** + * TODO: Update to use new model + */ + toOldJSON() { + return { + id: this.id, + name: this.name, + folders: (this.libraryFolders || []).map((f) => f.toOldJSON()), + displayOrder: this.displayOrder, + icon: this.icon, + mediaType: this.mediaType, + provider: this.provider, + settings: { + ...this.settings + }, + lastScan: this.lastScan?.valueOf() || null, + lastScanVersion: this.lastScanVersion, + createdAt: this.createdAt.valueOf(), + lastUpdate: this.updatedAt.valueOf() + } + } } module.exports = Library diff --git a/server/models/LibraryFolder.js b/server/models/LibraryFolder.js index db607547d0..71d532176f 100644 --- a/server/models/LibraryFolder.js +++ b/server/models/LibraryFolder.js @@ -42,6 +42,18 @@ class LibraryFolder extends Model { }) LibraryFolder.belongsTo(library) } + + /** + * TODO: Update to use new model + */ + toOldJSON() { + return { + id: this.id, + fullPath: this.path, + libraryId: this.libraryId, + addedAt: this.createdAt.valueOf() + } + } } module.exports = LibraryFolder diff --git a/server/objects/Library.js b/server/objects/Library.js index 98b6ec393b..a16e7b01d1 100644 --- a/server/objects/Library.js +++ b/server/objects/Library.js @@ -1,4 +1,3 @@ -const uuidv4 = require('uuid').v4 const Folder = require('./Folder') const LibrarySettings = require('./settings/LibrarySettings') const { filePathToPOSIX } = require('../utils/fileUtils') @@ -28,15 +27,9 @@ class Library { } } - get folderPaths() { - return this.folders.map((f) => f.fullPath) - } get isPodcast() { return this.mediaType === 'podcast' } - get isMusic() { - return this.mediaType === 'music' - } get isBook() { return this.mediaType === 'book' } @@ -98,61 +91,5 @@ class Library { lastUpdate: this.lastUpdate } } - - update(payload) { - let hasUpdates = false - - const keysToCheck = ['name', 'provider', 'mediaType', 'icon'] - keysToCheck.forEach((key) => { - if (payload[key] && payload[key] !== this[key]) { - this[key] = payload[key] - hasUpdates = true - } - }) - - if (payload.settings && this.settings.update(payload.settings)) { - hasUpdates = true - } - - if (!isNaN(payload.displayOrder) && payload.displayOrder !== this.displayOrder) { - this.displayOrder = Number(payload.displayOrder) - hasUpdates = true - } - if (payload.folders) { - const newFolders = payload.folders.filter((f) => !f.id) - const removedFolders = this.folders.filter((f) => !payload.folders.some((_f) => _f.id === f.id)) - - if (removedFolders.length) { - const removedFolderIds = removedFolders.map((f) => f.id) - this.folders = this.folders.filter((f) => !removedFolderIds.includes(f.id)) - } - - if (newFolders.length) { - newFolders.forEach((folderData) => { - folderData.libraryId = this.id - const newFolder = new Folder() - newFolder.setData(folderData) - this.folders.push(newFolder) - }) - } - - if (newFolders.length || removedFolders.length) { - hasUpdates = true - } - } - if (hasUpdates) { - this.lastUpdate = Date.now() - } - return hasUpdates - } - - checkFullPathInLibrary(fullPath) { - fullPath = filePathToPOSIX(fullPath) - return this.folders.find((folder) => fullPath.startsWith(filePathToPOSIX(folder.fullPath))) - } - - getFolderById(id) { - return this.folders.find((folder) => folder.id === id) - } } module.exports = Library diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 54cd97c094..e6e5a69427 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -53,6 +53,7 @@ class ApiRouter { this.audioMetadataManager = Server.audioMetadataManager /** @type {import('../managers/RssFeedManager')} */ this.rssFeedManager = Server.rssFeedManager + /** @type {import('../managers/CronManager')} */ this.cronManager = Server.cronManager /** @type {import('../managers/NotificationManager')} */ this.notificationManager = Server.notificationManager diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js index 0a48ac60a3..cbb8edea01 100644 --- a/server/utils/migrations/dbMigration.js +++ b/server/utils/migrations/dbMigration.js @@ -1268,7 +1268,7 @@ async function handleOldLibraries(ctx) { return false } const folderPaths = ol.folders?.map((f) => f.fullPath) || [] - return folderPaths.join(',') === library.folderPaths.join(',') + return folderPaths.join(',') === library.folders.map((f) => f.fullPath).join(',') }) if (matchingOldLibrary) { diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index 128df6fde7..7f95d0ecc4 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -173,16 +173,16 @@ module.exports = { /** * Search library items * @param {import('../../models/User')} user - * @param {import('../../objects/Library')} oldLibrary + * @param {import('../../models/Library')} library * @param {string} query * @param {number} limit * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}} */ - search(user, oldLibrary, query, limit) { - if (oldLibrary.isBook) { - return libraryItemsBookFilters.search(user, oldLibrary, query, limit, 0) + search(user, library, query, limit) { + if (library.isBook) { + return libraryItemsBookFilters.search(user, library, query, limit, 0) } else { - return libraryItemsPodcastFilters.search(user, oldLibrary, query, limit, 0) + return libraryItemsPodcastFilters.search(user, library, query, limit, 0) } }, diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index da356b3ee6..4ea28b4df3 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -966,13 +966,13 @@ module.exports = { /** * Search books, authors, series * @param {import('../../models/User')} user - * @param {import('../../objects/Library')} oldLibrary + * @param {import('../../models/Library')} library * @param {string} query * @param {number} limit * @param {number} offset * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}} */ - async search(user, oldLibrary, query, limit, offset) { + async search(user, library, query, limit, offset) { const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) const normalizedQuery = query @@ -1006,7 +1006,7 @@ module.exports = { { model: Database.libraryItemModel, where: { - libraryId: oldLibrary.id + libraryId: library.id } }, { @@ -1047,7 +1047,7 @@ module.exports = { const narratorMatches = [] const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { replacements: { - libraryId: oldLibrary.id, + libraryId: library.id, limit, offset }, @@ -1064,7 +1064,7 @@ module.exports = { const tagMatches = [] const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, { replacements: { - libraryId: oldLibrary.id, + libraryId: library.id, limit, offset }, @@ -1081,7 +1081,7 @@ module.exports = { const genreMatches = [] const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, { replacements: { - libraryId: oldLibrary.id, + libraryId: library.id, limit, offset }, @@ -1101,7 +1101,7 @@ module.exports = { [Sequelize.Op.and]: [ Sequelize.literal(matchName), { - libraryId: oldLibrary.id + libraryId: library.id } ] }, @@ -1136,7 +1136,7 @@ module.exports = { } // Search authors - const authorMatches = await authorFilters.search(oldLibrary.id, normalizedQuery, limit, offset) + const authorMatches = await authorFilters.search(library.id, normalizedQuery, limit, offset) return { book: itemMatches, diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 1fe6dbc5cb..77eb50120b 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -306,13 +306,13 @@ module.exports = { /** * Search podcasts * @param {import('../../models/User')} user - * @param {import('../../objects/Library')} oldLibrary + * @param {import('../../models/Library')} library * @param {string} query * @param {number} limit * @param {number} offset * @returns {{podcast:object[], tags:object[]}} */ - async search(user, oldLibrary, query, limit, offset) { + async search(user, library, query, limit, offset) { const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user) const normalizedQuery = query @@ -345,7 +345,7 @@ module.exports = { { model: Database.libraryItemModel, where: { - libraryId: oldLibrary.id + libraryId: library.id } } ], @@ -372,7 +372,7 @@ module.exports = { const tagMatches = [] const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, { replacements: { - libraryId: oldLibrary.id, + libraryId: library.id, limit, offset }, @@ -389,7 +389,7 @@ module.exports = { const genreMatches = [] const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, { replacements: { - libraryId: oldLibrary.id, + libraryId: library.id, limit, offset }, From 159ccd807fd97c50d8cc9aa2f2a484c64ba33d0e Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 24 Aug 2024 16:09:54 -0500 Subject: [PATCH 003/539] Updates to migrate off of old library model --- server/controllers/LibraryController.js | 20 +- server/controllers/MiscController.js | 6 +- server/controllers/PodcastController.js | 4 +- server/managers/NotificationManager.js | 2 +- server/models/LibraryItem.js | 8 +- server/scanner/LibraryScanner.js | 176 ++++++++++-------- server/utils/libraryHelpers.js | 2 +- server/utils/queries/libraryFilters.js | 44 ++--- .../utils/queries/libraryItemsBookFilters.js | 4 +- .../queries/libraryItemsPodcastFilters.js | 6 +- server/utils/queries/seriesFilters.js | 2 +- 11 files changed, 141 insertions(+), 133 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index cc9e6e2e55..9d9ed2eecb 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -507,15 +507,13 @@ class LibraryController { payload.offset = payload.page * payload.limit // TODO: Temporary way of handling collapse sub-series. Either remove feature or handle through sql queries - // TODO: Update to new library model - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) const filterByGroup = payload.filterBy?.split('.').shift() const filterByValue = filterByGroup ? libraryFilters.decode(payload.filterBy.replace(`${filterByGroup}.`, '')) : null if (filterByGroup === 'series' && filterByValue !== 'no-series' && payload.collapseseries) { const seriesId = libraryFilters.decode(payload.filterBy.split('.')[1]) - payload.results = await libraryHelpers.handleCollapseSubseries(payload, seriesId, req.user, oldLibrary) + payload.results = await libraryHelpers.handleCollapseSubseries(payload, seriesId, req.user, req.library) } else { - const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(oldLibrary, req.user, payload) + const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload) payload.results = libraryItems payload.total = count } @@ -589,9 +587,6 @@ class LibraryController { * @param {Response} res */ async getAllSeriesForLibrary(req, res) { - // TODO: Update to new library model - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) - const include = (req.query.include || '') .split(',') .map((v) => v.trim().toLowerCase()) @@ -610,7 +605,7 @@ class LibraryController { } const offset = payload.page * payload.limit - const { series, count } = await seriesFilters.getFilteredSeries(oldLibrary, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset) + const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset) payload.total = count payload.results = series @@ -741,14 +736,12 @@ class LibraryController { * @param {Response} res */ async getUserPersonalizedShelves(req, res) { - // TODO: Update to new library model - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10 const include = (req.query.include || '') .split(',') .map((v) => v.trim().toLowerCase()) .filter((v) => !!v) - const shelves = await Database.libraryItemModel.getPersonalizedShelves(oldLibrary, req.user, include, limitPerShelf) + const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf) res.json(shelves) } @@ -1098,8 +1091,7 @@ class LibraryController { if (req.library.mediaType !== 'podcast') { return res.sendStatus(404) } - // TODO: Update to new library model - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) + const payload = { episodes: [], limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0, @@ -1107,7 +1099,7 @@ class LibraryController { } const offset = payload.page * payload.limit - payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, oldLibrary, payload.limit, offset) + payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, req.library, payload.limit, offset) res.json(payload) } diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index ac6afff727..d9c1c445e6 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -44,11 +44,11 @@ class MiscController { const files = Object.values(req.files) const { title, author, series, folder: folderId, library: libraryId } = req.body - const library = await Database.libraryModel.getOldById(libraryId) + const library = await Database.libraryModel.findByPk(libraryId) if (!library) { return res.status(404).send(`Library not found with id ${libraryId}`) } - const folder = library.folders.find((fold) => fold.id === folderId) + const folder = library.libraryFolders.find((fold) => fold.id === folderId) if (!folder) { return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`) } @@ -63,7 +63,7 @@ class MiscController { // before sanitizing all the directory parts to remove illegal chars and finally prepending // the base folder path const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part)) - const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts]) + const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts]) await fs.ensureDir(outputDirectory) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 30688c7687..976e1ac917 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -38,13 +38,13 @@ class PodcastController { } const payload = req.body - const library = await Database.libraryModel.getOldById(payload.libraryId) + const library = await Database.libraryModel.findByPk(payload.libraryId) if (!library) { Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`) return res.status(404).send('Library not found') } - const folder = library.folders.find((fold) => fold.id === payload.folderId) + const folder = library.libraryFolders.find((fold) => fold.id === payload.folderId) if (!folder) { Logger.error(`[PodcastController] Create: Folder not found "${payload.folderId}"`) return res.status(404).send('Folder not found') diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index f8bd75114a..a59c128154 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -23,7 +23,7 @@ class NotificationManager { } Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) - const library = await Database.libraryModel.getOldById(libraryItem.libraryId) + const library = await Database.libraryModel.findByPk(libraryItem.libraryId) const eventData = { libraryItemId: libraryItem.id, libraryId: libraryItem.libraryId, diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index b986ed54bd..54f85aded9 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -11,8 +11,6 @@ const LibraryFile = require('../objects/files/LibraryFile') const Book = require('./Book') const Podcast = require('./Podcast') -const ShareManager = require('../managers/ShareManager') - /** * @typedef LibraryFileObject * @property {string} ino @@ -559,14 +557,14 @@ class LibraryItem extends Model { /** * Get library items using filter and sort - * @param {oldLibrary} library + * @param {import('./Library')} library * @param {import('./User')} user * @param {object} options * @returns {{ libraryItems:oldLibraryItem[], count:number }} */ static async getByFilterAndSort(library, user, options) { let start = Date.now() - const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library, user, options) + const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library.id, user, options) Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`) return { @@ -602,7 +600,7 @@ class LibraryItem extends Model { /** * Get home page data personalized shelves - * @param {oldLibrary} library + * @param {import('./Library')} library * @param {import('./User')} user * @param {string[]} include * @param {number} limit diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 6d72e8704c..bbdde32833 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -26,27 +26,27 @@ class LibraryScanner { } /** - * @param {string} libraryId + * @param {string} libraryId * @returns {boolean} */ isLibraryScanning(libraryId) { - return this.librariesScanning.some(ls => ls.id === libraryId) + return this.librariesScanning.some((ls) => ls.id === libraryId) } /** - * - * @param {string} libraryId + * + * @param {string} libraryId */ setCancelLibraryScan(libraryId) { - const libraryScanning = this.librariesScanning.find(ls => ls.id === libraryId) + const libraryScanning = this.librariesScanning.find((ls) => ls.id === libraryId) if (!libraryScanning) return this.cancelLibraryScan[libraryId] = true } /** - * - * @param {import('../objects/Library')} library - * @param {boolean} [forceRescan] + * + * @param {import('../objects/Library')} library + * @param {boolean} [forceRescan] */ async scan(library, forceRescan = false) { if (this.isLibraryScanning(library.id)) { @@ -89,7 +89,7 @@ class LibraryScanner { libraryScan.setComplete() Logger.info(`[LibraryScanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`) - this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id) + this.librariesScanning = this.librariesScanning.filter((ls) => ls.id !== library.id) if (canceled && !libraryScan.totalResults) { task.setFinished('Scan canceled') @@ -116,8 +116,8 @@ class LibraryScanner { } /** - * - * @param {import('./LibraryScan')} libraryScan + * + * @param {import('./LibraryScan')} libraryScan * @param {boolean} forceRescan * @returns {Promise} true if scan canceled */ @@ -151,14 +151,10 @@ class LibraryScanner { let oldLibraryItemsUpdated = [] for (const existingLibraryItem of existingLibraryItems) { // First try to find matching library item with exact file path - let libraryItemData = libraryItemDataFound.find(lid => lid.path === existingLibraryItem.path) + let libraryItemData = libraryItemDataFound.find((lid) => lid.path === existingLibraryItem.path) if (!libraryItemData) { // Fallback to finding matching library item with matching inode value - libraryItemData = libraryItemDataFound.find(lid => - ItemToItemInoMatch(lid, existingLibraryItem) || - ItemToFileInoMatch(lid, existingLibraryItem) || - ItemToFileInoMatch(existingLibraryItem, lid) - ) + libraryItemData = libraryItemDataFound.find((lid) => ItemToItemInoMatch(lid, existingLibraryItem) || ItemToFileInoMatch(lid, existingLibraryItem) || ItemToFileInoMatch(existingLibraryItem, lid)) if (libraryItemData) { libraryScan.addLog(LogLevel.INFO, `Library item with path "${existingLibraryItem.path}" was not found, but library item inode "${existingLibraryItem.ino}" was found at path "${libraryItemData.path}"`) } @@ -166,7 +162,7 @@ class LibraryScanner { if (!libraryItemData) { // Podcast folder can have no episodes and still be valid - if (libraryScan.libraryMediaType === 'podcast' && await fs.pathExists(existingLibraryItem.path)) { + if (libraryScan.libraryMediaType === 'podcast' && (await fs.pathExists(existingLibraryItem.path))) { libraryScan.addLog(LogLevel.INFO, `Library item "${existingLibraryItem.relPath}" folder exists but has no episodes`) } else { libraryScan.addLog(LogLevel.WARN, `Library Item "${existingLibraryItem.path}" (inode: ${existingLibraryItem.ino}) is missing`) @@ -184,7 +180,7 @@ class LibraryScanner { } } } else { - libraryItemDataFound = libraryItemDataFound.filter(lidf => lidf !== libraryItemData) + libraryItemDataFound = libraryItemDataFound.filter((lidf) => lidf !== libraryItemData) let libraryItemDataUpdated = await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan) if (libraryItemDataUpdated || forceRescan) { if (forceRescan || libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) { @@ -210,7 +206,10 @@ class LibraryScanner { // Emit item updates in chunks of 10 to client if (oldLibraryItemsUpdated.length === 10) { // TODO: Should only emit to clients where library item is accessible - SocketAuthority.emitter('items_updated', oldLibraryItemsUpdated.map(li => li.toJSONExpanded())) + SocketAuthority.emitter( + 'items_updated', + oldLibraryItemsUpdated.map((li) => li.toJSONExpanded()) + ) oldLibraryItemsUpdated = [] } @@ -219,7 +218,10 @@ class LibraryScanner { // Emit item updates to client if (oldLibraryItemsUpdated.length) { // TODO: Should only emit to clients where library item is accessible - SocketAuthority.emitter('items_updated', oldLibraryItemsUpdated.map(li => li.toJSONExpanded())) + SocketAuthority.emitter( + 'items_updated', + oldLibraryItemsUpdated.map((li) => li.toJSONExpanded()) + ) } // Authors and series that were removed from books should be removed if they are now empty @@ -228,15 +230,18 @@ class LibraryScanner { // Update missing library items if (libraryItemIdsMissing.length) { libraryScan.addLog(LogLevel.INFO, `Updating ${libraryItemIdsMissing.length} library items missing`) - await Database.libraryItemModel.update({ - isMissing: true, - lastScan: Date.now(), - lastScanVersion: packageJson.version - }, { - where: { - id: libraryItemIdsMissing + await Database.libraryItemModel.update( + { + isMissing: true, + lastScan: Date.now(), + lastScanVersion: packageJson.version + }, + { + where: { + id: libraryItemIdsMissing + } } - }) + ) } if (this.cancelLibraryScan[libraryScan.libraryId]) return true @@ -256,7 +261,10 @@ class LibraryScanner { // Emit new items in chunks of 10 to client if (newOldLibraryItems.length === 10) { // TODO: Should only emit to clients where library item is accessible - SocketAuthority.emitter('items_added', newOldLibraryItems.map(li => li.toJSONExpanded())) + SocketAuthority.emitter( + 'items_added', + newOldLibraryItems.map((li) => li.toJSONExpanded()) + ) newOldLibraryItems = [] } @@ -265,15 +273,18 @@ class LibraryScanner { // Emit new items to client if (newOldLibraryItems.length) { // TODO: Should only emit to clients where library item is accessible - SocketAuthority.emitter('items_added', newOldLibraryItems.map(li => li.toJSONExpanded())) + SocketAuthority.emitter( + 'items_added', + newOldLibraryItems.map((li) => li.toJSONExpanded()) + ) } } } /** * Get scan data for library folder - * @param {import('../objects/Library')} library - * @param {import('../objects/Folder')} folder + * @param {import('../objects/Library')} library + * @param {import('../objects/Folder')} folder * @returns {LibraryItemScanData[]} */ async scanFolder(library, folder) { @@ -321,27 +332,29 @@ class LibraryScanner { continue } - items.push(new LibraryItemScanData({ - libraryFolderId: folder.id, - libraryId: folder.libraryId, - mediaType: library.mediaType, - ino: libraryItemFolderStats.ino, - mtimeMs: libraryItemFolderStats.mtimeMs || 0, - ctimeMs: libraryItemFolderStats.ctimeMs || 0, - birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, - path: libraryItemData.path, - relPath: libraryItemData.relPath, - isFile, - mediaMetadata: libraryItemData.mediaMetadata || null, - libraryFiles: fileObjs - })) + items.push( + new LibraryItemScanData({ + libraryFolderId: folder.id, + libraryId: folder.libraryId, + mediaType: library.mediaType, + ino: libraryItemFolderStats.ino, + mtimeMs: libraryItemFolderStats.mtimeMs || 0, + ctimeMs: libraryItemFolderStats.ctimeMs || 0, + birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, + path: libraryItemData.path, + relPath: libraryItemData.relPath, + isFile, + mediaMetadata: libraryItemData.mediaMetadata || null, + libraryFiles: fileObjs + }) + ) } return items } /** * Scan files changed from Watcher - * @param {import('../Watcher').PendingFileUpdate[]} fileUpdates + * @param {import('../Watcher').PendingFileUpdate[]} fileUpdates * @param {Task} pendingTask */ async scanFilesChanged(fileUpdates, pendingTask) { @@ -366,7 +379,7 @@ class LibraryScanner { for (const folderId in folderGroups) { const libraryId = folderGroups[folderId].libraryId - // const library = await Database.libraryModel.getOldById(libraryId) + const library = await Database.libraryModel.findByPk(libraryId, { include: { model: Database.libraryFolderModel, @@ -381,7 +394,7 @@ class LibraryScanner { } const folder = library.libraryFolders[0] - const relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath) + const relFilePaths = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUpdate.relPath) const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths) if (!Object.keys(fileUpdateGroup).length) { @@ -432,7 +445,7 @@ class LibraryScanner { /** * Group array of PendingFileUpdate from Watcher by folder - * @param {import('../Watcher').PendingFileUpdate[]} fileUpdates + * @param {import('../Watcher').PendingFileUpdate[]} fileUpdates * @returns {Record} */ getFileUpdatesGrouped(fileUpdates) { @@ -453,9 +466,9 @@ class LibraryScanner { /** * Scan grouped paths for library folder coming from Watcher - * @param {import('../models/Library')} library - * @param {import('../models/LibraryFolder')} folder - * @param {Record} fileUpdateGroup + * @param {import('../models/Library')} library + * @param {import('../models/LibraryFolder')} folder + * @param {Record} fileUpdateGroup * @returns {Promise>} */ async scanFolderUpdates(library, folder, fileUpdateGroup) { @@ -471,7 +484,7 @@ class LibraryScanner { for (const itemDir in updateGroup) { if (isSingleMediaFile(fileUpdateGroup, itemDir)) continue // Media in root path - const itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/')) + const itemDirNestedFiles = fileUpdateGroup[itemDir].filter((b) => b.includes('/')) if (!itemDirNestedFiles.length) continue const firstNest = itemDirNestedFiles[0].split('/').shift() @@ -523,7 +536,15 @@ class LibraryScanner { const potentialChildDirs = [fullPath] for (let i = 0; i < itemDirParts.length; i++) { - potentialChildDirs.push(Path.posix.join(fileUtils.filePathToPOSIX(folder.path), itemDir.split('/').slice(0, -1 - i).join('/'))) + potentialChildDirs.push( + Path.posix.join( + fileUtils.filePathToPOSIX(folder.path), + itemDir + .split('/') + .slice(0, -1 - i) + .join('/') + ) + ) } // Check if book dir group is already an item @@ -535,10 +556,7 @@ class LibraryScanner { let updatedLibraryItemDetails = {} if (!existingLibraryItem) { const isSingleMedia = isSingleMediaFile(fileUpdateGroup, itemDir) - existingLibraryItem = - await findLibraryItemByItemToItemInoMatch(library.id, fullPath) || - await findLibraryItemByItemToFileInoMatch(library.id, fullPath, isSingleMedia) || - await findLibraryItemByFileToItemInoMatch(library.id, fullPath, isSingleMedia, fileUpdateGroup[itemDir]) + existingLibraryItem = (await findLibraryItemByItemToItemInoMatch(library.id, fullPath)) || (await findLibraryItemByItemToFileInoMatch(library.id, fullPath, isSingleMedia)) || (await findLibraryItemByFileToItemInoMatch(library.id, fullPath, isSingleMedia, fileUpdateGroup[itemDir])) if (existingLibraryItem) { // Update library item paths for scan existingLibraryItem.path = fullPath @@ -603,7 +621,7 @@ class LibraryScanner { module.exports = new LibraryScanner() function ItemToFileInoMatch(libraryItem1, libraryItem2) { - return libraryItem1.isFile && libraryItem2.libraryFiles.some(lf => lf.ino === libraryItem1.ino) + return libraryItem1.isFile && libraryItem2.libraryFiles.some((lf) => lf.ino === libraryItem1.ino) } function ItemToItemInoMatch(libraryItem1, libraryItem2) { @@ -611,9 +629,7 @@ function ItemToItemInoMatch(libraryItem1, libraryItem2) { } function hasAudioFiles(fileUpdateGroup, itemDir) { - return isSingleMediaFile(fileUpdateGroup, itemDir) ? - scanUtils.checkFilepathIsAudioFile(fileUpdateGroup[itemDir]) : - fileUpdateGroup[itemDir].some(scanUtils.checkFilepathIsAudioFile) + return isSingleMediaFile(fileUpdateGroup, itemDir) ? scanUtils.checkFilepathIsAudioFile(fileUpdateGroup[itemDir]) : fileUpdateGroup[itemDir].some(scanUtils.checkFilepathIsAudioFile) } function isSingleMediaFile(fileUpdateGroup, itemDir) { @@ -627,8 +643,7 @@ async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) { libraryId: libraryId, ino: ino }) - if (existingLibraryItem) - Logger.debug(`[LibraryScanner] Found library item with matching inode "${ino}" at path "${existingLibraryItem.path}"`) + if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with matching inode "${ino}" at path "${existingLibraryItem.path}"`) return existingLibraryItem } @@ -637,18 +652,20 @@ async function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingle // check if it was moved from another folder by comparing the ino to the library files const ino = await fileUtils.getIno(fullPath) if (!ino) return null - const existingLibraryItem = await Database.libraryItemModel.findOneOld([ + const existingLibraryItem = await Database.libraryItemModel.findOneOld( + [ + { + libraryId: libraryId + }, + sequelize.where(sequelize.literal('(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(json_each.value) AND json_each.value->>"$.ino" = :inode)'), { + [sequelize.Op.gt]: 0 + }) + ], { - libraryId: libraryId - }, - sequelize.where(sequelize.literal('(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(json_each.value) AND json_each.value->>"$.ino" = :inode)'), { - [sequelize.Op.gt]: 0 - }) - ], { - inode: ino - }) - if (existingLibraryItem) - Logger.debug(`[LibraryScanner] Found library item with a library file matching inode "${ino}" at path "${existingLibraryItem.path}"`) + inode: ino + } + ) + if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with a library file matching inode "${ino}" at path "${existingLibraryItem.path}"`) return existingLibraryItem } @@ -667,7 +684,6 @@ async function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingle [sequelize.Op.in]: itemFileInos } }) - if (existingLibraryItem) - Logger.debug(`[LibraryScanner] Found library item with inode matching one of "${itemFileInos.join(',')}" at path "${existingLibraryItem.path}"`) + if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with inode matching one of "${itemFileInos.join(',')}" at path "${existingLibraryItem.path}"`) return existingLibraryItem -} \ No newline at end of file +} diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index ad71ee3fad..664bd6e301 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -83,7 +83,7 @@ module.exports = { * @param {*} payload * @param {string} seriesId * @param {import('../models/User')} user - * @param {import('../objects/Library')} library + * @param {import('../models/Library')} library * @returns {Object[]} */ async handleCollapseSubseries(payload, seriesId, user, library) { diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 471a1c0b48..2268ef2111 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -15,12 +15,12 @@ module.exports = { /** * Get library items using filter and sort - * @param {import('../../objects/Library')} library + * @param {string} libraryId * @param {import('../../models/User')} user * @param {object} options * @returns {object} { libraryItems:LibraryItem[], count:number } */ - async getFilteredLibraryItems(library, user, options) { + async getFilteredLibraryItems(libraryId, user, options) { const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options let filterValue = null @@ -33,22 +33,22 @@ module.exports = { } if (mediaType === 'book') { - return libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) + return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) } else { - return libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) + return libraryItemsPodcastFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) } }, /** * Get library items for continue listening & continue reading shelves - * @param {import('../../objects/Library')} library + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {string[]} include * @param {number} limit * @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>} */ async getMediaItemsInProgress(library, user, include, limit) { - if (library.mediaType === 'book') { + if (library.isBook) { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0, true) return { items: libraryItems.map((li) => { @@ -78,14 +78,14 @@ module.exports = { /** * Get library items for most recently added shelf - * @param {import('../../objects/Library')} library + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {string[]} include * @param {number} limit * @returns {object} { libraryItems:LibraryItem[], count:number } */ async getLibraryItemsMostRecentlyAdded(library, user, include, limit) { - if (library.mediaType === 'book') { + if (library.isBook) { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0) return { libraryItems: libraryItems.map((li) => { @@ -126,7 +126,7 @@ module.exports = { /** * Get library items for continue series shelf - * @param {import('../../objects/Library')} library + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {string[]} include * @param {number} limit @@ -154,14 +154,15 @@ module.exports = { /** * Get library items or podcast episodes for the "Listen Again" and "Read Again" shelf - * @param {import('../../objects/Library')} library + * + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {string[]} include * @param {number} limit - * @returns {object} { items:object[], count:number } + * @returns {Promise<{ items:oldLibraryItem[], count:number }>} */ async getMediaFinished(library, user, include, limit) { - if (library.mediaType === 'book') { + if (library.isBook) { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0) return { items: libraryItems.map((li) => { @@ -191,7 +192,7 @@ module.exports = { /** * Get series for recent series shelf - * @param {import('../../objects/Library')} library + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {string[]} include * @param {number} limit @@ -316,10 +317,11 @@ module.exports = { /** * Get most recently created authors for "Newest Authors" shelf * Author must be linked to at least 1 book - * @param {oldLibrary} library + * + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {number} limit - * @returns {object} { authors:oldAuthor[], count:number } + * @returns {Promise<{ authors:oldAuthor[], count:number }>} */ async getNewestAuthors(library, user, limit) { if (library.mediaType !== 'book') return { authors: [], count: 0 } @@ -359,11 +361,11 @@ module.exports = { /** * Get book library items for the "Discover" shelf - * @param {oldLibrary} library + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {string[]} include * @param {number} limit - * @returns {object} {libraryItems:oldLibraryItem[], count:number} + * @returns {Promise<{libraryItems:oldLibraryItem[], count:number}>} */ async getLibraryItemsToDiscover(library, user, include, limit) { if (library.mediaType !== 'book') return { libraryItems: [], count: 0 } @@ -386,10 +388,10 @@ module.exports = { /** * Get podcast episodes most recently added - * @param {oldLibrary} library + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {number} limit - * @returns {object} {libraryItems:oldLibraryItem[], count:number} + * @returns {Promise<{libraryItems:oldLibraryItem[], count:number}>} */ async getNewestPodcastEpisodes(library, user, limit) { if (library.mediaType !== 'podcast') return { libraryItems: [], count: 0 } @@ -411,7 +413,7 @@ module.exports = { * @param {import('../../models/User')} user * @param {number} limit * @param {number} offset - * @returns {Promise} { libraryItems:LibraryItem[], count:number } + * @returns {Promise<{ libraryItems:import('../../objects/LibraryItem')[], count:number }>} */ async getLibraryItemsForAuthor(author, user, limit, offset) { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(author.libraryId, user, 'authors', author.id, 'addedAt', true, false, [], limit, offset) @@ -424,7 +426,7 @@ module.exports = { /** * Get book library items in a collection * @param {oldCollection} collection - * @returns {Promise} + * @returns {Promise} */ getLibraryItemsForCollection(collection) { return libraryItemsBookFilters.getLibraryItemsForCollection(collection) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 4ea28b4df3..094095ce7c 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -636,7 +636,7 @@ module.exports = { * 2. Has no books in progress * 3. Has at least 1 unfinished book * TODO: Reduce queries - * @param {import('../../objects/Library')} library + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {string[]} include * @param {number} limit @@ -911,7 +911,7 @@ module.exports = { /** * Get book library items in a collection * @param {oldCollection} collection - * @returns {Promise} + * @returns {Promise} */ async getLibraryItemsForCollection(collection) { if (!collection?.books?.length) { diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 77eb50120b..50163edfbd 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -412,12 +412,12 @@ module.exports = { /** * Most recent podcast episodes not finished * @param {import('../../models/User')} user - * @param {import('../../objects/Library')} oldLibrary + * @param {import('../../models/Library')} library * @param {number} limit * @param {number} offset * @returns {Promise} */ - async getRecentEpisodes(user, oldLibrary, limit, offset) { + async getRecentEpisodes(user, library, limit, offset) { const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user) const episodes = await Database.podcastEpisodeModel.findAll({ @@ -435,7 +435,7 @@ module.exports = { include: { model: Database.libraryItemModel, where: { - libraryId: oldLibrary.id + libraryId: library.id } } }, diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js index c03c13bff2..b7afcf85f9 100644 --- a/server/utils/queries/seriesFilters.js +++ b/server/utils/queries/seriesFilters.js @@ -11,7 +11,7 @@ module.exports = { /** * Get series filtered and sorted * - * @param {import('../../objects/Library')} library + * @param {import('../../models/Library')} library * @param {import('../../models/User')} user * @param {string} filterBy * @param {string} sortBy From c72eac998703e2d3b5bcaf25a95bb7bdcb149855 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 25 Aug 2024 17:13:09 -0500 Subject: [PATCH 004/539] Fix:Check if book is already being merged before allowing to start #3331 --- server/controllers/ToolsController.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js index 32cd5a6c97..adc91870b7 100644 --- a/server/controllers/ToolsController.js +++ b/server/controllers/ToolsController.js @@ -29,12 +29,17 @@ class ToolsController { if (req.libraryItem.mediaType !== 'book') { Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`) - return res.status(500).send('Invalid library item: not a book') + return res.status(400).send('Invalid library item: not a book') } if (req.libraryItem.media.tracks.length <= 0) { Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`) - return res.status(500).send('Invalid audiobook: no audio tracks') + return res.status(400).send('Invalid audiobook: no audio tracks') + } + + if (this.abMergeManager.getPendingTaskByLibraryItemId(req.libraryItem.id)) { + Logger.error(`[MiscController] encodeM4b: Audiobook ${req.params.id} is already in queue or processing`) + return res.status(400).send('Audiobook is already in queue or processing') } const options = req.query || {} From 0ae054c5d73c3bb86629d28f2c002902629441f4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 26 Aug 2024 17:02:29 -0500 Subject: [PATCH 005/539] Update tools endpoint status codes --- server/controllers/ToolsController.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js index adc91870b7..8aa9f83267 100644 --- a/server/controllers/ToolsController.js +++ b/server/controllers/ToolsController.js @@ -38,8 +38,8 @@ class ToolsController { } if (this.abMergeManager.getPendingTaskByLibraryItemId(req.libraryItem.id)) { - Logger.error(`[MiscController] encodeM4b: Audiobook ${req.params.id} is already in queue or processing`) - return res.status(400).send('Audiobook is already in queue or processing') + Logger.error(`[MiscController] encodeM4b: Audiobook ${req.params.id} is already processing`) + return res.status(400).send('Audiobook is already processing') } const options = req.query || {} @@ -78,12 +78,12 @@ class ToolsController { async embedAudioFileMetadata(req, res) { if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { Logger.error(`[ToolsController] Invalid library item`) - return res.sendStatus(500) + return res.sendStatus(400) } if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(req.libraryItem.id)) { Logger.error(`[ToolsController] Library item (${req.libraryItem.id}) is already in queue or processing`) - return res.status(500).send('Library item is already in queue or processing') + return res.status(400).send('Library item is already in queue or processing') } const options = { @@ -125,12 +125,12 @@ class ToolsController { if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) { Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`) - return res.sendStatus(500) + return res.sendStatus(400) } if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(libraryItemId)) { Logger.error(`[ToolsController] Batch embed library item (${libraryItemId}) is already in queue or processing`) - return res.status(500).send('Library item is already in queue or processing') + return res.status(400).send('Library item is already in queue or processing') } libraryItems.push(libraryItem) From 9b3553095683dcd15a983966d7347d8f2d6c8815 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 27 Aug 2024 16:53:18 -0500 Subject: [PATCH 006/539] Fix memorystore constructor validation --- server/libs/memorystore/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/libs/memorystore/index.js b/server/libs/memorystore/index.js index 06fd37fb93..d47853c023 100644 --- a/server/libs/memorystore/index.js +++ b/server/libs/memorystore/index.js @@ -21,7 +21,7 @@ const { Store } = require('express-session') */ module.exports = class MemoryStore extends Store { constructor(checkPeriod, ttl, max) { - if (typeof checkPeriod !== 'number' || typeof checkPeriod !== 'number' || typeof checkPeriod !== 'number') { + if (typeof checkPeriod !== 'number' || typeof ttl !== 'number' || typeof max !== 'number') { throw Error('All arguments must be provided') } super() From 799acf5db81f67b70addadfcf4142e1d30bb36be Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 9 Aug 2024 23:11:47 +0000 Subject: [PATCH 007/539] Translated using Weblate (Spanish) Currently translated at 100.0% (872 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index 4f9c6141ca..34cb4b4e87 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -771,6 +771,24 @@ "PlaceholderNewPlaylist": "Nuevo nombre de la lista de reproducción", "PlaceholderSearch": "Buscar..", "PlaceholderSearchEpisode": "Buscar Episodio..", + "StatsAuthorsAdded": "autores añadidos", + "StatsBooksAdded": "libros añadidos", + "StatsBooksAdditional": "Algunas adiciones incluyen…", + "StatsBooksFinished": "libros terminados", + "StatsBooksFinishedThisYear": "Algunos libros terminados este año…", + "StatsBooksListenedTo": "libros escuchados", + "StatsCollectionGrewTo": "Tu colección de libros creció hasta…", + "StatsSessions": "sesiones", + "StatsSpentListening": "dedicado a la escucha", + "StatsTopAuthor": "AUTOR DESTACADO", + "StatsTopAuthors": "AUTORES DESTACADOS", + "StatsTopGenre": "GÉNERO PRINCIPAL", + "StatsTopGenres": "GÉNEROS PRINCIPALES", + "StatsTopMonth": "DESTACADO DEL MES", + "StatsTopNarrator": "NARRADOR DESTACADO", + "StatsTopNarrators": "NARRADORES DESTACADOS", + "StatsTotalDuration": "Con una duración total de…", + "StatsYearInReview": "RESEÑA DEL AÑO", "ToastAccountUpdateFailed": "Error al actualizar cuenta", "ToastAccountUpdateSuccess": "Cuenta actualizada", "ToastAuthorImageRemoveFailed": "Error al eliminar la imagen", @@ -806,6 +824,7 @@ "ToastCollectionUpdateSuccess": "Colección actualizada", "ToastDeleteFileFailed": "Error el eliminar archivo", "ToastDeleteFileSuccess": "Archivo eliminado", + "ToastErrorCannotShare": "No se puede compartir de forma nativa en este dispositivo", "ToastFailedToLoadData": "Error al cargar data", "ToastItemCoverUpdateFailed": "Error al actualizar la portada del elemento", "ToastItemCoverUpdateSuccess": "Portada del elemento actualizada", From 1e0a9918fd3cc71242ab9ed45145675a17d5cce9 Mon Sep 17 00:00:00 2001 From: Charlie Date: Sat, 10 Aug 2024 00:20:37 +0000 Subject: [PATCH 008/539] Translated using Weblate (French) Currently translated at 99.8% (871 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 32520aaa5e..f514f0a6a9 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -9,7 +9,7 @@ "ButtonApply": "Appliquer", "ButtonApplyChapters": "Appliquer aux chapitres", "ButtonAuthors": "Auteurs", - "ButtonBack": "Retour", + "ButtonBack": "Reculer", "ButtonBrowseForFolder": "Naviguer vers le répertoire", "ButtonCancel": "Annuler", "ButtonCancelEncode": "Annuler l’encodage", @@ -346,6 +346,8 @@ "LabelIntervalEveryHour": "Toutes les heures", "LabelInvert": "Inverser", "LabelItem": "Élément", + "LabelJumpBackwardAmount": "Dans le lecteur, reculer de", + "LabelJumpForwardAmount": "Dans le lecteur, avancer de", "LabelLanguage": "Langue", "LabelLanguageDefaultServer": "Langue par défaut", "LabelLanguages": "Langues", @@ -769,6 +771,24 @@ "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", "PlaceholderSearch": "Recherche…", "PlaceholderSearchEpisode": "Recherche d’épisode…", + "StatsAuthorsAdded": "auteurs ajoutés", + "StatsBooksAdded": "livres ajoutés", + "StatsBooksAdditional": "Les ajouts comprennent…", + "StatsBooksFinished": "livres terminés", + "StatsBooksFinishedThisYear": "Quelques livres terminés cette année…", + "StatsBooksListenedTo": "livres écoutés", + "StatsCollectionGrewTo": "Votre collection de livres a atteint…", + "StatsSessions": "sessions", + "StatsSpentListening": "temps passé à l’écoute", + "StatsTopAuthor": "TOP AUTEUR", + "StatsTopAuthors": "TOP AUTEURS", + "StatsTopGenre": "TOP GENRE", + "StatsTopGenres": "TOP GENRES", + "StatsTopMonth": "TOP MOIS", + "StatsTopNarrator": "TOP NARRATEUR", + "StatsTopNarrators": "TOP NARRATEURS", + "StatsTotalDuration": "Pour une durée totale de…", + "StatsYearInReview": "BILAN DE L’ANNÉE", "ToastAccountUpdateFailed": "Échec de la mise à jour du compte", "ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAuthorImageRemoveFailed": "Échec de la suppression de l’image", @@ -804,6 +824,7 @@ "ToastCollectionUpdateSuccess": "Collection mise à jour", "ToastDeleteFileFailed": "Échec de la suppression du fichier", "ToastDeleteFileSuccess": "Fichier supprimé", + "ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil", "ToastFailedToLoadData": "Échec du chargement des données", "ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de l’élément", "ToastItemCoverUpdateSuccess": "Couverture mise à jour", From 41bd9a9358ad538930c4e27482569a9a657b53cc Mon Sep 17 00:00:00 2001 From: Mario Date: Sat, 10 Aug 2024 06:46:42 +0000 Subject: [PATCH 009/539] Translated using Weblate (German) Currently translated at 99.8% (871 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 749a0973d8..0a2cc40b22 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -455,7 +455,7 @@ "LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort", "LabelRSSFeedURL": "RSS Feed URL", - "LabelRandomly": "Zufällig", + "LabelRandomly": "zufällig", "LabelReAddSeriesToContinueListening": "Serien erneut zur Fortsetzungsliste hinzufügen", "LabelRead": "Lesen", "LabelReadAgain": "Noch einmal Lesen", @@ -771,6 +771,24 @@ "PlaceholderNewPlaylist": "Neuer Wiedergabelistenname", "PlaceholderSearch": "Suche...", "PlaceholderSearchEpisode": "Suche Episode...", + "StatsAuthorsAdded": "Autoren hinzugefügt", + "StatsBooksAdded": "Bücher hinzugefügt", + "StatsBooksAdditional": "Einige Zusätze inkludiert…", + "StatsBooksFinished": "Beendete Bücher", + "StatsBooksFinishedThisYear": "Einige Bücher, die dieses Jahr beendet wurden…", + "StatsBooksListenedTo": "gehörte Bücher", + "StatsCollectionGrewTo": "Deine Bückersammlung ist gewachsen auf…", + "StatsSessions": "Sitzungen", + "StatsSpentListening": "Hörzeit", + "StatsTopAuthor": "TOP AUTOR", + "StatsTopAuthors": "TOP AUTOREN", + "StatsTopGenre": "TOP GENRE", + "StatsTopGenres": "TOP GENRES", + "StatsTopMonth": "TOP MONAT", + "StatsTopNarrator": "TOP SPRECHER", + "StatsTopNarrators": "TOP SPRECHER", + "StatsTotalDuration": "Mit einer totalen Dauer von…", + "StatsYearInReview": "DAS JAHR IM RÜCKBLICK", "ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen", "ToastAccountUpdateSuccess": "Konto aktualisiert", "ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden", @@ -806,6 +824,7 @@ "ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden", "ToastDeleteFileSuccess": "Datei gelöscht", + "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät geteilt werden", "ToastFailedToLoadData": "Daten laden fehlgeschlagen", "ToastItemCoverUpdateFailed": "Fehler bei der Aktualisierung des Titelbildes", "ToastItemCoverUpdateSuccess": "Titelbild aktualisiert", From 840304ee04ccde5bf6bf9b79bfa58142899d596a Mon Sep 17 00:00:00 2001 From: Vito0912 Date: Sat, 10 Aug 2024 10:44:08 +0000 Subject: [PATCH 010/539] Translated using Weblate (German) Currently translated at 99.8% (871 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 0a2cc40b22..90300d8b2b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -523,7 +523,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", "LabelShare": "Teilen", - "LabelShareOpen": "Teilen öffnen", + "LabelShareOpen": "Aktuell geteilt", "LabelShareURL": "URL teilen", "LabelShowAll": "Alles anzeigen", "LabelShowSeconds": "Zeige Sekunden", @@ -773,7 +773,7 @@ "PlaceholderSearchEpisode": "Suche Episode...", "StatsAuthorsAdded": "Autoren hinzugefügt", "StatsBooksAdded": "Bücher hinzugefügt", - "StatsBooksAdditional": "Einige Zusätze inkludiert…", + "StatsBooksAdditional": "Einige der Neuzugänge umfassen…", "StatsBooksFinished": "Beendete Bücher", "StatsBooksFinishedThisYear": "Einige Bücher, die dieses Jahr beendet wurden…", "StatsBooksListenedTo": "gehörte Bücher", From 04da8812df9c0c83edaf93b67139acb44cbce848 Mon Sep 17 00:00:00 2001 From: Mario Date: Sat, 10 Aug 2024 18:31:16 +0000 Subject: [PATCH 011/539] Translated using Weblate (German) Currently translated at 99.8% (871 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 90300d8b2b..23672cf4ad 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -84,7 +84,7 @@ "ButtonSelectFolderPath": "Ordnerpfad auswählen", "ButtonSeries": "Serien", "ButtonSetChaptersFromTracks": "Kapitelerstellung aus Audiodateien", - "ButtonShare": "Teilen", + "ButtonShare": "Freigeben", "ButtonShiftTimes": "Zeitverschiebung", "ButtonShow": "Anzeigen", "ButtonStartM4BEncode": "M4B-Kodierung starten", @@ -522,9 +522,9 @@ "LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern", "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", - "LabelShare": "Teilen", - "LabelShareOpen": "Aktuell geteilt", - "LabelShareURL": "URL teilen", + "LabelShare": "Freigeben", + "LabelShareOpen": "Freigabe", + "LabelShareURL": "Freigabe URL", "LabelShowAll": "Alles anzeigen", "LabelShowSeconds": "Zeige Sekunden", "LabelShowSubtitles": "Untertitel anzeigen", @@ -747,7 +747,7 @@ "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageShareExpirationWillBe": "Läuft am {0} ab", "MessageShareExpiresIn": "Läuft in {0} ab", - "MessageShareURLWillBe": "Der geteilte Link wird {0} sein.", + "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein.", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", "MessageThinking": "Nachdenken...", "MessageUploaderItemFailed": "Hochladen fehlgeschlagen", @@ -824,7 +824,7 @@ "ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden", "ToastDeleteFileSuccess": "Datei gelöscht", - "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät geteilt werden", + "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", "ToastFailedToLoadData": "Daten laden fehlgeschlagen", "ToastItemCoverUpdateFailed": "Fehler bei der Aktualisierung des Titelbildes", "ToastItemCoverUpdateSuccess": "Titelbild aktualisiert", From 45b13571a5b10b1c8455b305018f18c24e9d6d82 Mon Sep 17 00:00:00 2001 From: burghy86 Date: Sat, 10 Aug 2024 20:00:57 +0000 Subject: [PATCH 012/539] Translated using Weblate (Italian) Currently translated at 100.0% (872 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/strings/it.json b/client/strings/it.json index c1251621f0..66e4930e98 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -455,6 +455,7 @@ "LabelRSSFeedPreventIndexing": "Impedisci l'indicizzazione", "LabelRSSFeedSlug": "Parole chiave del flusso RSS", "LabelRSSFeedURL": "RSS Feed URL", + "LabelRandomly": "Casualmente", "LabelReAddSeriesToContinueListening": "Aggiungi di nuovo la serie per continuare ad ascoltare", "LabelRead": "Leggi", "LabelReadAgain": "Leggi ancora", @@ -770,6 +771,24 @@ "PlaceholderNewPlaylist": "Nome nuova playlist", "PlaceholderSearch": "Cerca..", "PlaceholderSearchEpisode": "Cerca Episodio..", + "StatsAuthorsAdded": "autori aggiunti", + "StatsBooksAdded": "Libri aggiunti", + "StatsBooksAdditional": "Alcune aggiunte includono…", + "StatsBooksFinished": "Libri Finiti", + "StatsBooksFinishedThisYear": "Alcuni libri terminati quest'anno…", + "StatsBooksListenedTo": "libri ascoltati", + "StatsCollectionGrewTo": "La tua collezione di libri è cresciuta fino a…", + "StatsSessions": "sessioni", + "StatsSpentListening": "trascorso ad ascoltare", + "StatsTopAuthor": "MIGLIOR AUTORE", + "StatsTopAuthors": "MIGLIORI AUTORI", + "StatsTopGenre": "MIGLIOR GENERE", + "StatsTopGenres": "MIGLIORI GENERI", + "StatsTopMonth": "MIGLIOR MESE", + "StatsTopNarrator": "MIGLIOR NARRATORE", + "StatsTopNarrators": "MIGLIORI NARRATORI", + "StatsTotalDuration": "Con una durata totale di…", + "StatsYearInReview": "ANNO IN RASSEGNA", "ToastAccountUpdateFailed": "Aggiornamento Account Fallito", "ToastAccountUpdateSuccess": "Account Aggiornato", "ToastAuthorImageRemoveFailed": "Rimozione immagine autore Fallita", @@ -805,6 +824,7 @@ "ToastCollectionUpdateSuccess": "Raccolta aggiornata", "ToastDeleteFileFailed": "Impossibile eliminare il file", "ToastDeleteFileSuccess": "File eliminato", + "ToastErrorCannotShare": "Impossibile condividere in modo nativo su questo dispositivo", "ToastFailedToLoadData": "Impossibile caricare i dati", "ToastItemCoverUpdateFailed": "Errore Aggiornamento cover", "ToastItemCoverUpdateSuccess": "Cover aggiornata", From 2f99efcc60f5b55229a3a73803a28877198c6407 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Sun, 11 Aug 2024 12:09:03 +0000 Subject: [PATCH 013/539] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (872 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index b15eb99e9d..97f47390ce 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -325,7 +325,7 @@ "LabelHardDeleteFile": "完全删除文件", "LabelHasEbook": "有电子书", "LabelHasSupplementaryEbook": "有补充电子书", - "LabelHideSubtitles": "隐藏标题", + "LabelHideSubtitles": "隐藏副标题", "LabelHighestPriority": "最高优先级", "LabelHost": "主机", "LabelHour": "小时", @@ -771,6 +771,24 @@ "PlaceholderNewPlaylist": "输入播放列表名称", "PlaceholderSearch": "查找..", "PlaceholderSearchEpisode": "搜索剧集..", + "StatsAuthorsAdded": "添加作者", + "StatsBooksAdded": "添加书籍", + "StatsBooksAdditional": "一些新增内容包括…", + "StatsBooksFinished": "已完成书籍", + "StatsBooksFinishedThisYear": "今年完成的一些书…", + "StatsBooksListenedTo": "听过的书", + "StatsCollectionGrewTo": "您的藏书已增长到…", + "StatsSessions": "会话", + "StatsSpentListening": "花时间聆听", + "StatsTopAuthor": "热门作者", + "StatsTopAuthors": "热门作者", + "StatsTopGenre": "热门流派", + "StatsTopGenres": "热门流派", + "StatsTopMonth": "最佳月份", + "StatsTopNarrator": "最佳叙述者", + "StatsTopNarrators": "最佳叙述者", + "StatsTotalDuration": "总时长为…", + "StatsYearInReview": "年度回顾", "ToastAccountUpdateFailed": "账户更新失败", "ToastAccountUpdateSuccess": "帐户已更新", "ToastAuthorImageRemoveFailed": "作者图像删除失败", @@ -806,6 +824,7 @@ "ToastCollectionUpdateSuccess": "收藏夹已更新", "ToastDeleteFileFailed": "删除文件失败", "ToastDeleteFileSuccess": "文件已删除", + "ToastErrorCannotShare": "无法在此设备上本地共享", "ToastFailedToLoadData": "加载数据失败", "ToastItemCoverUpdateFailed": "更新项目封面失败", "ToastItemCoverUpdateSuccess": "项目封面已更新", From f79bfae95daed4576ec5d96194a477d3d7d4c303 Mon Sep 17 00:00:00 2001 From: Charlie Date: Sun, 11 Aug 2024 21:58:56 +0000 Subject: [PATCH 014/539] Translated using Weblate (French) Currently translated at 99.8% (871 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index f514f0a6a9..21c8d15bb0 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -15,8 +15,8 @@ "ButtonCancelEncode": "Annuler l’encodage", "ButtonChangeRootPassword": "Modifier le mot de passe Administrateur", "ButtonCheckAndDownloadNewEpisodes": "Vérifier et télécharger de nouveaux épisodes", - "ButtonChooseAFolder": "Choisir un dossier", - "ButtonChooseFiles": "Choisir les fichiers", + "ButtonChooseAFolder": "Sélectionner un dossier", + "ButtonChooseFiles": "Sélectionner des fichiers", "ButtonClearFilter": "Effacer le filtre", "ButtonCloseFeed": "Fermer le flux", "ButtonCollections": "Collections", @@ -66,7 +66,7 @@ "ButtonReadLess": "Lire moins", "ButtonReadMore": "Lire la suite", "ButtonRefresh": "Rafraîchir", - "ButtonRemove": "Retirer", + "ButtonRemove": "Supprimer", "ButtonRemoveAll": "Supprimer tout", "ButtonRemoveAllLibraryItems": "Supprimer tous les éléments de la bibliothèque", "ButtonRemoveFromContinueListening": "Ne plus continuer à écouter", From ff4cbc6d5fb37e8d4e004ac2b77ed06556be0404 Mon Sep 17 00:00:00 2001 From: Vito0912 Date: Mon, 12 Aug 2024 10:15:47 +0000 Subject: [PATCH 015/539] Translated using Weblate (German) Currently translated at 100.0% (872 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 23672cf4ad..95ff0fb666 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -779,7 +779,7 @@ "StatsBooksListenedTo": "gehörte Bücher", "StatsCollectionGrewTo": "Deine Bückersammlung ist gewachsen auf…", "StatsSessions": "Sitzungen", - "StatsSpentListening": "Hörzeit", + "StatsSpentListening": "zugehört", "StatsTopAuthor": "TOP AUTOR", "StatsTopAuthors": "TOP AUTOREN", "StatsTopGenre": "TOP GENRE", From 25cc24fca5f00bafa9551a70a9d81e280ec8c428 Mon Sep 17 00:00:00 2001 From: Vito0912 Date: Tue, 13 Aug 2024 18:29:37 +0000 Subject: [PATCH 016/539] Translated using Weblate (German) Currently translated at 100.0% (872 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 95ff0fb666..6401db9128 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -455,7 +455,7 @@ "LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort", "LabelRSSFeedURL": "RSS Feed URL", - "LabelRandomly": "zufällig", + "LabelRandomly": "Zufällig", "LabelReAddSeriesToContinueListening": "Serien erneut zur Fortsetzungsliste hinzufügen", "LabelRead": "Lesen", "LabelReadAgain": "Noch einmal Lesen", From 2441bb1cece802f01fd51bbe3f6c9ac1140ef33d Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Tue, 13 Aug 2024 18:03:44 +0000 Subject: [PATCH 017/539] Translated using Weblate (Spanish) Currently translated at 100.0% (872 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/es.json b/client/strings/es.json index 34cb4b4e87..69700a736c 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -455,7 +455,7 @@ "LabelRSSFeedPreventIndexing": "Prevenir indexado", "LabelRSSFeedSlug": "Fuente RSS Slug", "LabelRSSFeedURL": "URL de Fuente RSS", - "LabelRandomly": "Aleatoriamente", + "LabelRandomly": "Aleatorio", "LabelReAddSeriesToContinueListening": "Volver a agregar la serie para continuar escuchándola", "LabelRead": "Leído", "LabelReadAgain": "Volver a leer", From a8f459e4fab1b04b12df9baa6dea36079e6dc827 Mon Sep 17 00:00:00 2001 From: Fredrik Lindqvist Date: Tue, 13 Aug 2024 13:00:28 +0000 Subject: [PATCH 018/539] Translated using Weblate (Swedish) Currently translated at 83.6% (729 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 59da191fb7..c301109b78 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -9,7 +9,7 @@ "ButtonApply": "Tillämpa", "ButtonApplyChapters": "Tillämpa kapitel", "ButtonAuthors": "Författare", - "ButtonBack": "Back", + "ButtonBack": "Tillbaka", "ButtonBrowseForFolder": "Bläddra efter mapp", "ButtonCancel": "Avbryt", "ButtonCancelEncode": "Avbryt kodning", @@ -247,9 +247,9 @@ "LabelCollections": "Samlingar", "LabelComplete": "Komplett", "LabelConfirmPassword": "Bekräfta lösenord", - "LabelContinueListening": "Fortsätt lyssna", - "LabelContinueReading": "Fortsätt läsa", - "LabelContinueSeries": "Fortsätt serie", + "LabelContinueListening": "Fortsätt Lyssna", + "LabelContinueReading": "Fortsätt Läsa", + "LabelContinueSeries": "Forsätt Serie", "LabelCover": "Omslag", "LabelCoverImageURL": "URL till omslagsbild", "LabelCreatedAt": "Skapad vid", @@ -276,7 +276,7 @@ "LabelDurationComparisonShorter": "({0} shorter)", "LabelDurationFound": "Varaktighet hittad:", "LabelEbook": "E-bok", - "LabelEbooks": "E-böcker", + "LabelEbooks": "Eböcker", "LabelEdit": "Redigera", "LabelEmail": "E-post", "LabelEmailSettingsFromAddress": "Från adress", @@ -288,6 +288,7 @@ "LabelEmbeddedCover": "Inbäddat omslag", "LabelEnable": "Aktivera", "LabelEnd": "Slut", + "LabelEndOfChapter": "Slut av kapitel", "LabelEpisode": "Avsnitt", "LabelEpisodeTitle": "Avsnittsrubrik", "LabelEpisodeType": "Avsnittstyp", @@ -316,8 +317,8 @@ "LabelGenre": "Genre", "LabelGenres": "Genrer", "LabelHardDeleteFile": "Hård radering av fil", - "LabelHasEbook": "Har e-bok", - "LabelHasSupplementaryEbook": "Har kompletterande e-bok", + "LabelHasEbook": "Har E-bok", + "LabelHasSupplementaryEbook": "Har komplimenterande E-bok", "LabelHighestPriority": "Highest priority", "LabelHost": "Värd", "LabelHour": "Timme", From 93fa6ba4662ad767e1700f13d31c63ffafe48678 Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Wed, 14 Aug 2024 20:01:45 +0000 Subject: [PATCH 019/539] Translated using Weblate (Czech) Currently translated at 96.6% (843 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 50 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index 3ffb08df1c..c1e3bbec3a 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -59,6 +59,7 @@ "ButtonPurgeItemsCache": "Vyčistit mezipaměť položek", "ButtonQueueAddItem": "Přidat do fronty", "ButtonQueueRemoveItem": "Odstranit z fronty", + "ButtonQuickEmbedMetadata": "Rychle Zapsat Metadata", "ButtonQuickMatch": "Rychlé přiřazení", "ButtonReScan": "Znovu prohledat", "ButtonRead": "Číst", @@ -88,6 +89,7 @@ "ButtonShow": "Zobrazit", "ButtonStartM4BEncode": "Spustit kódování M4B", "ButtonStartMetadataEmbed": "Spustit vkládání metadat", + "ButtonStats": "Statistiky", "ButtonSubmit": "Odeslat", "ButtonTest": "Test", "ButtonUpload": "Nahrát", @@ -154,6 +156,7 @@ "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Oprávnění", "HeaderPlayerQueue": "Fronta přehrávače", + "HeaderPlayerSettings": "Nastavení přehrávače", "HeaderPlaylist": "Seznam skladeb", "HeaderPlaylistItems": "Položky seznamu přehrávání", "HeaderPodcastsToAdd": "Podcasty k přidání", @@ -226,7 +229,7 @@ "LabelBackupLocation": "Umístění zálohy", "LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování", "LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups", - "LabelBackupsMaxBackupSize": "Maximální velikost zálohy (v GB)", + "LabelBackupsMaxBackupSize": "Maximální velikost zálohy (v GB) (0 bez omezení)", "LabelBackupsMaxBackupSizeHelp": "Ochrana proti chybné konfiguraci: Zálohování se nezdaří, pokud překročí nastavenou velikost.", "LabelBackupsNumberToKeep": "Počet záloh, které se mají uchovat", "LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.", @@ -258,6 +261,7 @@ "LabelCurrently": "Aktuálně:", "LabelCustomCronExpression": "Vlastní výraz cronu:", "LabelDatetime": "Datum a čas", + "LabelDays": "Dny", "LabelDeleteFromFileSystemCheckbox": "Smazat ze souborového systému (zrušte zaškrtnutí pro odstranění pouze z databáze)", "LabelDescription": "Popis", "LabelDeselectAll": "Odznačit vše", @@ -288,13 +292,16 @@ "LabelEmbeddedCover": "Vložená obálka", "LabelEnable": "Povolit", "LabelEnd": "Konec", + "LabelEndOfChapter": "Konec kapitoly", "LabelEpisode": "Epizoda", "LabelEpisodeTitle": "Název epizody", "LabelEpisodeType": "Typ epizody", "LabelExample": "Příklad", + "LabelExpandSeries": "Rozbalit série", "LabelExplicit": "Explicitní", "LabelExplicitChecked": "Explicitní (zaškrtnuto)", "LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)", + "LabelExportOPML": "Export OPML", "LabelFeedURL": "URL zdroje", "LabelFetchingMetadata": "Získávání metadat", "LabelFile": "Soubor", @@ -318,9 +325,11 @@ "LabelHardDeleteFile": "Trvale smazat soubor", "LabelHasEbook": "Obsahuje elektronickou knihu", "LabelHasSupplementaryEbook": "Obsahuje doplňkovou elektronickou knihu", + "LabelHideSubtitles": "Skrýt titulky", "LabelHighestPriority": "Nejvyšší priorita", "LabelHost": "Hostitel", "LabelHour": "Hodina", + "LabelHours": "Hodiny", "LabelIcon": "Ikona", "LabelImageURLFromTheWeb": "URL obrázku z webu", "LabelInProgress": "Probíhá", @@ -337,6 +346,8 @@ "LabelIntervalEveryHour": "Každou hodinu", "LabelInvert": "Invertovat", "LabelItem": "Položka", + "LabelJumpBackwardAmount": "Přeskočit zpět o", + "LabelJumpForwardAmount": "Přeskočit dopředu o", "LabelLanguage": "Jazyk", "LabelLanguageDefaultServer": "Výchozí jazyk serveru", "LabelLanguages": "Jazyky", @@ -371,6 +382,7 @@ "LabelMetadataOrderOfPrecedenceDescription": "Zdroje metadat s vyšší prioritou budou mít přednost před zdroji metadat s nižší prioritou.", "LabelMetadataProvider": "Poskytovatel metadat", "LabelMinute": "Minuta", + "LabelMinutes": "Minuty", "LabelMissing": "Chybějící", "LabelMissingEbook": "Nemá elektronickou knihu", "LabelMissingSupplementaryEbook": "Nemá žádnou doplňkovou elektronickou knihu", @@ -410,6 +422,7 @@ "LabelOverwrite": "Přepsat", "LabelPassword": "Heslo", "LabelPath": "Cesta", + "LabelPermanent": "Trvalé", "LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám", "LabelPermissionsAccessAllTags": "Má přístup ke všem značkám", "LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu", @@ -442,6 +455,7 @@ "LabelRSSFeedPreventIndexing": "Zabránit indexování", "LabelRSSFeedSlug": "RSS kanál Slug", "LabelRSSFeedURL": "URL RSS kanálu", + "LabelRandomly": "Náhodně", "LabelRead": "Číst", "LabelReadAgain": "Číst znovu", "LabelReadEbookWithoutProgress": "Číst e-knihu bez zachování průběhu", @@ -507,8 +521,11 @@ "LabelSettingsStoreMetadataWithItem": "Uložit metadata s položkou", "LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny", "LabelSettingsTimeFormat": "Formát času", + "LabelShare": "Sdílet", + "LabelShareURL": "Sdílet URL", "LabelShowAll": "Zobrazit vše", "LabelShowSeconds": "Zobrazit sekundy", + "LabelShowSubtitles": "Zobrazit titulky", "LabelSize": "Velikost", "LabelSleepTimer": "Časovač vypnutí", "LabelSlug": "Slug", @@ -546,6 +563,10 @@ "LabelThemeDark": "Tmavé", "LabelThemeLight": "Světlé", "LabelTimeBase": "Časová základna", + "LabelTimeDurationXHours": "{0} hodin", + "LabelTimeDurationXMinutes": "{0} minut", + "LabelTimeDurationXSeconds": "{0} sekund", + "LabelTimeInMinutes": "Čas v minutách", "LabelTimeListened": "Čas poslechu", "LabelTimeListenedToday": "Čas poslechu dnes", "LabelTimeRemaining": "{0} zbývá", @@ -585,9 +606,12 @@ "LabelVersion": "Verze", "LabelViewBookmarks": "Zobrazit záložky", "LabelViewChapters": "Zobrazit kapitoly", + "LabelViewPlayerSettings": "Zobrazit nastavení přehrávače", "LabelViewQueue": "Zobrazit frontu přehrávače", "LabelVolume": "Hlasitost", "LabelWeekdaysToRun": "Dny v týdnu ke spuštění", + "LabelXBooks": "{0} knih", + "LabelXItems": "{0} položky", "LabelYearReviewHide": "Skrýt rok v přehledu", "LabelYearReviewShow": "Zobrazit rok v přehledu", "LabelYourAudiobookDuration": "Doba trvání vaší audioknihy", @@ -597,6 +621,9 @@ "MessageAddToPlayerQueue": "Přidat do fronty přehrávače", "MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci Apprise API nebo API, které bude zpracovávat stejné požadavky.
Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese http://192.168.1.1:8337 pak byste měli zadat http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v /metadata/items a /metadata/authors. Zálohy ne zahrnují všechny soubory uložené ve složkách knihovny.", + "MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy", + "MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.", + "MessageBackupsLocationPathEmpty": "Umístění záloh nemůže být prázdné", "MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.", "MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku", "MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály", @@ -642,8 +669,9 @@ "MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?", "MessageDownloadingEpisode": "Stahuji epizodu", "MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop", + "MessageEmbedFailed": "Vložení selhalo!", "MessageEmbedFinished": "Vložení dokončeno!", - "MessageEpisodesQueuedForDownload": "{0} epizody zařazené do fronty ke stažení", + "MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení", "MessageEreaderDevices": "Aby bylo zajištěno doručení elektronických knih, může být nutné přidat výše uvedenou e-mailovou adresu jako platného odesílatele pro každé zařízení uvedené níže.", "MessageFeedURLWillBe": "URL zdroje bude {0}", "MessageFetching": "Stahování...", @@ -696,6 +724,7 @@ "MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace", "MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb", "MessageNotYetImplemented": "Ještě není implementováno", + "MessageOpmlPreviewNote": "Poznámka: Toto je náhled načteného OMPL souboru. Aktuální název podcastu bude načten z RSS feedu.", "MessageOr": "nebo", "MessagePauseChapter": "Pozastavit přehrávání kapitoly", "MessagePlayChapter": "Poslechnout si začátek kapitoly", @@ -714,6 +743,9 @@ "MessageSelected": "{0} vybráno", "MessageServerCouldNotBeReached": "Server je nedostupný", "MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru", + "MessageShareExpirationWillBe": "Expiruje {0}", + "MessageShareExpiresIn": "Expiruje za {0}", + "MessageShareURLWillBe": "Sdílené URL bude {0}", "MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?", "MessageThinking": "Přemýšlení...", "MessageUploaderItemFailed": "Nahrávání se nezdařilo", @@ -737,6 +769,19 @@ "PlaceholderNewPlaylist": "Nový název seznamu přehrávání", "PlaceholderSearch": "Hledat..", "PlaceholderSearchEpisode": "Hledat epizodu..", + "StatsAuthorsAdded": "autoři přidáni", + "StatsBooksAdded": "knihy přidány", + "StatsBooksFinished": "dokončené knihy", + "StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…", + "StatsSessions": "sezení", + "StatsSpentListening": "stráveno posloucháním", + "StatsTopAuthor": "TOP AUTOR", + "StatsTopAuthors": "TOP AUTOŘI", + "StatsTopGenre": "TOP ŽÁNR", + "StatsTopGenres": "TOP ŽÁNRY", + "StatsTopMonth": "TOP MĚSÍC", + "StatsTotalDuration": "S celkovou dobou…", + "StatsYearInReview": "ROK V PŘEHLEDU", "ToastAccountUpdateFailed": "Aktualizace účtu se nezdařila", "ToastAccountUpdateSuccess": "Účet aktualizován", "ToastAuthorImageRemoveFailed": "Nepodařilo se odstranit obrázek", @@ -772,6 +817,7 @@ "ToastCollectionUpdateSuccess": "Kolekce aktualizována", "ToastDeleteFileFailed": "Nepodařilo se smazat soubor", "ToastDeleteFileSuccess": "Soubor smazán", + "ToastErrorCannotShare": "Na tomto zařízení nelze nativně sdílet", "ToastFailedToLoadData": "Nepodařilo se načíst data", "ToastItemCoverUpdateFailed": "Aktualizace obálky se nezdařila", "ToastItemCoverUpdateSuccess": "Obálka předmětu byl aktualizována", From e6ecc28001784effdce0d79f3a3230fd969eb6c2 Mon Sep 17 00:00:00 2001 From: lecoq Date: Wed, 14 Aug 2024 13:22:07 +0000 Subject: [PATCH 020/539] Translated using Weblate (French) Currently translated at 99.8% (871 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 21c8d15bb0..4c5d67a784 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -287,7 +287,7 @@ "LabelEmailSettingsRejectUnauthorized": "Rejeter les certificats non autorisés", "LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « Attaque de l’homme du milieu ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.", "LabelEmailSettingsSecure": "Sécurisé", - "LabelEmailSettingsSecureHelp": "Si vous activez cette option, TLS sera utiliser lors de la connexion au serveur. Sinon, TLS est utilisé uniquement si le serveur supporte l’extension STARTTLS. Dans la plupart des cas, activez l’option, vous vous connecterai sur le port 465. Pour le port 587 ou 25, désactiver l’option. (source : nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsSecureHelp": "Si vous activez cette option, le protocole TLS sera utilisé lors de la connexion au serveur. Sinon, le protocole TLS sera utilisé uniquement si le serveur supporte l’extension STARTTLS. Dans la plupart des cas, activez l’option, vous vous connecterez sur le port 465. Pour le port 587 ou 25, désactivez l’option. (source : nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Adresse de test", "LabelEmbeddedCover": "Couverture du livre intégrée", "LabelEnable": "Activer", @@ -674,7 +674,7 @@ "MessageEmbedFailed": "Échec de l’intégration !", "MessageEmbedFinished": "Intégration terminée !", "MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement", - "MessageEreaderDevices": "Pour garantir l’envoie des livres électroniques, il se peut que vous deviez ajouter l’adresse électronique ci-dessus en tant qu’expéditeur valide pour chaque appareil répertorié ci-dessous.", + "MessageEreaderDevices": "Pour garantir l’envoi des livres électroniques, il se peut que vous deviez ajouter l’adresse électronique ci-dessus en tant qu’expéditeur valide pour chaque appareil répertorié ci-dessous.", "MessageFeedURLWillBe": "L’URL du flux sera {0}", "MessageFetching": "Récupération…", "MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme s’ils étaient nouveaux.", From c938685679bf180c56dcf9ebeb21d89b27c77f05 Mon Sep 17 00:00:00 2001 From: Illia Pyshniak Date: Wed, 14 Aug 2024 20:18:16 +0000 Subject: [PATCH 021/539] Translated using Weblate (Ukrainian) Currently translated at 100.0% (872 of 872 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index ba6575d8d3..27ef6b0275 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -455,6 +455,7 @@ "LabelRSSFeedPreventIndexing": "Запобігати індексації", "LabelRSSFeedSlug": "Назва RSS-каналу", "LabelRSSFeedURL": "Адреса RSS-каналу", + "LabelRandomly": "Випадково", "LabelReAddSeriesToContinueListening": "Заново додати серії до Продовжити слухати", "LabelRead": "Читати", "LabelReadAgain": "Читати знову", @@ -611,6 +612,8 @@ "LabelViewQueue": "Переглянути чергу відтворення", "LabelVolume": "Гучність", "LabelWeekdaysToRun": "Виконувати у дні", + "LabelXBooks": "{0} книг", + "LabelXItems": "{0} елементів", "LabelYearReviewHide": "Сховати підсумки року", "LabelYearReviewShow": "Переглянути підсумки року", "LabelYourAudiobookDuration": "Тривалість вашої аудіокниги", @@ -668,6 +671,7 @@ "MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?", "MessageDownloadingEpisode": "Завантаження епізоду", "MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку", + "MessageEmbedFailed": "Не вдалося вбудувати!", "MessageEmbedFinished": "Вбудовано!", "MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}", "MessageEreaderDevices": "Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.", @@ -767,6 +771,24 @@ "PlaceholderNewPlaylist": "Нова назва списку", "PlaceholderSearch": "Пошук...", "PlaceholderSearchEpisode": "Шукати епізод...", + "StatsAuthorsAdded": "авторів додано", + "StatsBooksAdded": "книг додано", + "StatsBooksAdditional": "Було додано…", + "StatsBooksFinished": "книг завершено", + "StatsBooksFinishedThisYear": "Дещо з завершеного цьогоріч…", + "StatsBooksListenedTo": "книг, які слухали", + "StatsCollectionGrewTo": "Ваша колекція книг зросла до…", + "StatsSessions": "сесій", + "StatsSpentListening": "слухали", + "StatsTopAuthor": "УЛЮБЛЕНИЙ АВТОР", + "StatsTopAuthors": "УЛЮБЛЕНІ АВТОРИ", + "StatsTopGenre": "УЛЮБЛЕНИЙ ЖАНР", + "StatsTopGenres": "УЛЮБЛЕНІ ЖАНРИ", + "StatsTopMonth": "НАЙКРАЩИЙ МІСЯЦЬ", + "StatsTopNarrator": "УЛЮБЛЕНИЙ ЧИТЕЦЬ", + "StatsTopNarrators": "УЛЮБЛЕНІ ЧИТЦІ", + "StatsTotalDuration": "Загальною довжиною…", + "StatsYearInReview": "ОГЛЯД РОКУ", "ToastAccountUpdateFailed": "Не вдалося оновити профіль", "ToastAccountUpdateSuccess": "Профіль оновлено", "ToastAuthorImageRemoveFailed": "Не вдалося видалити зображення", @@ -802,6 +824,7 @@ "ToastCollectionUpdateSuccess": "Добірку оновлено", "ToastDeleteFileFailed": "Не вдалося видалити файл", "ToastDeleteFileSuccess": "Файл видалено", + "ToastErrorCannotShare": "Не можна типово поширити на цей пристрій", "ToastFailedToLoadData": "Не вдалося завантажити дані", "ToastItemCoverUpdateFailed": "Не вдалося оновити обкладинку", "ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено", From 7d2d5f6bf439fad72328d3ec71942b238a835bae Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Thu, 15 Aug 2024 22:37:08 +0000 Subject: [PATCH 022/539] Translated using Weblate (Spanish) Currently translated at 100.0% (874 of 874 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index 69700a736c..e7bc5e062e 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -246,6 +246,7 @@ "LabelClosePlayer": "Cerrar reproductor", "LabelCodec": "Codec", "LabelCollapseSeries": "Colapsar serie", + "LabelCollapseSubSeries": "Contraer la subserie", "LabelCollection": "Colección", "LabelCollections": "Colecciones", "LabelComplete": "Completo", @@ -298,6 +299,7 @@ "LabelEpisodeType": "Tipo de Episodio", "LabelExample": "Ejemplo", "LabelExpandSeries": "Ampliar serie", + "LabelExpandSubSeries": "Expandir la subserie", "LabelExplicit": "Explicito", "LabelExplicitChecked": "Explícito (marcado)", "LabelExplicitUnchecked": "No Explícito (sin marcar)", From 05aabb2843be50d7904abc09ae1faeee61d15dfd Mon Sep 17 00:00:00 2001 From: Mario Date: Fri, 16 Aug 2024 09:28:45 +0000 Subject: [PATCH 023/539] Translated using Weblate (German) Currently translated at 100.0% (874 of 874 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 6401db9128..ecb5659150 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -245,7 +245,8 @@ "LabelClickForMoreInfo": "Klicken für mehr Informationen", "LabelClosePlayer": "Player schließen", "LabelCodec": "Codec", - "LabelCollapseSeries": "Serien zusammenfassen", + "LabelCollapseSeries": "Serien einklappen", + "LabelCollapseSubSeries": "Unterserien einklappen", "LabelCollection": "Sammlung", "LabelCollections": "Sammlungen", "LabelComplete": "Vollständig", @@ -297,7 +298,8 @@ "LabelEpisodeTitle": "Episodentitel", "LabelEpisodeType": "Episodentyp", "LabelExample": "Beispiel", - "LabelExpandSeries": "Serie erweitern", + "LabelExpandSeries": "Serie ausklappen", + "LabelExpandSubSeries": "Unterserie ausklappen", "LabelExplicit": "Explizit (Altersbeschränkung)", "LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)", "LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)", From 6224163ecd2438f851de08135c90f26665bb5c83 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Sat, 17 Aug 2024 18:49:27 +0000 Subject: [PATCH 024/539] Translated using Weblate (Spanish) Currently translated at 100.0% (875 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/es.json b/client/strings/es.json index e7bc5e062e..0d13985e26 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -46,6 +46,7 @@ "ButtonNevermind": "Olvidar", "ButtonNext": "Next", "ButtonNextChapter": "Siguiente Capítulo", + "ButtonNextItemInQueue": "El siguiente elemento en cola", "ButtonOk": "Ok", "ButtonOpenFeed": "Abrir fuente", "ButtonOpenManager": "Abrir Editor", From b33a3cabf9293df01783401daa978c338350991d Mon Sep 17 00:00:00 2001 From: Mario Date: Sun, 18 Aug 2024 10:14:13 +0000 Subject: [PATCH 025/539] Translated using Weblate (German) Currently translated at 100.0% (875 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/de.json b/client/strings/de.json index ecb5659150..1c12a29abf 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -46,6 +46,7 @@ "ButtonNevermind": "Abbrechen", "ButtonNext": "Vor", "ButtonNextChapter": "Nächstes Kapitel", + "ButtonNextItemInQueue": "Das nächste Element in der Warteschlange", "ButtonOk": "Ok", "ButtonOpenFeed": "Feed öffnen", "ButtonOpenManager": "Manager öffnen", From fe14c26782c4ab36f8003499d19603c35d8658f9 Mon Sep 17 00:00:00 2001 From: Ahetek Date: Sun, 18 Aug 2024 18:21:18 +0000 Subject: [PATCH 026/539] Translated using Weblate (Polish) Currently translated at 90.7% (794 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/client/strings/pl.json b/client/strings/pl.json index 0fe8535dda..4467431ba4 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -46,6 +46,7 @@ "ButtonNevermind": "Anuluj", "ButtonNext": "Następny", "ButtonNextChapter": "Następny rozdział", + "ButtonNextItemInQueue": "Następny element w kolejce", "ButtonOk": "Ok", "ButtonOpenFeed": "Otwórz feed", "ButtonOpenManager": "Otwórz menadżera", @@ -59,6 +60,7 @@ "ButtonPurgeItemsCache": "Wyczyść dane tymczasowe pozycji", "ButtonQueueAddItem": "Dodaj do kolejki", "ButtonQueueRemoveItem": "Usuń z kolejki", + "ButtonQuickEmbedMetadata": "Szybkie wstawianie metadanych", "ButtonQuickMatch": "Szybkie dopasowanie", "ButtonReScan": "Ponowne skanowanie", "ButtonRead": "Czytaj", @@ -245,6 +247,7 @@ "LabelClosePlayer": "Zamknij odtwarzacz", "LabelCodec": "Codec", "LabelCollapseSeries": "Podsumuj serię", + "LabelCollapseSubSeries": "Zwiń podserie", "LabelCollection": "Kolekcja", "LabelCollections": "Kolekcje", "LabelComplete": "Ukończone", @@ -291,13 +294,17 @@ "LabelEmbeddedCover": "Wbudowana okładka", "LabelEnable": "Włącz", "LabelEnd": "Zakończ", + "LabelEndOfChapter": "Koniec rozdziału", "LabelEpisode": "Odcinek", "LabelEpisodeTitle": "Tytuł odcinka", "LabelEpisodeType": "Typ odcinka", "LabelExample": "Przykład", + "LabelExpandSeries": "Rozwiń serie", + "LabelExpandSubSeries": "Rozwiń podserie", "LabelExplicit": "Nieprzyzwoite", "LabelExplicitChecked": "Explicit (checked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)", + "LabelExportOPML": "Wyeksportuj OPML", "LabelFeedURL": "URL kanału", "LabelFetchingMetadata": "Pobieranie metadanych", "LabelFile": "Plik", @@ -449,6 +456,7 @@ "LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu", "LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedURL": "URL kanały RSS", + "LabelRandomly": "Losowo", "LabelReAddSeriesToContinueListening": "Ponownie Dodaj Serię do sekcji Kontunuuj Odtwarzanie", "LabelRead": "Czytaj", "LabelReadAgain": "Czytaj ponownie", @@ -558,6 +566,10 @@ "LabelThemeDark": "Ciemny", "LabelThemeLight": "Jasny", "LabelTimeBase": "Time Base", + "LabelTimeDurationXHours": "{0} godzin", + "LabelTimeDurationXMinutes": "{0} minuty", + "LabelTimeDurationXSeconds": "{0} sekundy", + "LabelTimeInMinutes": "Czas w minutach", "LabelTimeListened": "Czas odtwarzania", "LabelTimeListenedToday": "Czas odtwarzania dzisiaj", "LabelTimeRemaining": "Pozostało {0}", @@ -601,6 +613,8 @@ "LabelViewQueue": "Wyświetlaj kolejkę odtwarzania", "LabelVolume": "Głośność", "LabelWeekdaysToRun": "Dni tygodnia", + "LabelXBooks": "{0} książek", + "LabelXItems": "{0} elementów", "LabelYearReviewHide": "Ukryj Podsumowanie Roku", "LabelYearReviewShow": "Pokaż Podsumowanie Roku", "LabelYourAudiobookDuration": "Czas trwania audiobooka", @@ -658,6 +672,7 @@ "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Pobieranie odcinka", "MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów", + "MessageEmbedFailed": "Niepowodzenie wstawiania!", "MessageEmbedFinished": "Osadzanie zakończone!", "MessageEpisodesQueuedForDownload": "{0} odcinki w kolejce do pobrania", "MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.", @@ -712,6 +727,7 @@ "MessageNoUpdatesWereNecessary": "Brak aktualizacji", "MessageNoUserPlaylists": "Nie masz żadnych list odtwarzania", "MessageNotYetImplemented": "Jeszcze nie zaimplementowane", + "MessageOpmlPreviewNote": "Uwaga: To jest podgląd sparsowanego pliku OPML. Tytuł podcastu wzięty został z wątku RSS.", "MessageOr": "lub", "MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały", "MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału", @@ -756,6 +772,22 @@ "PlaceholderNewPlaylist": "Nowa nazwa playlisty", "PlaceholderSearch": "Szukanie..", "PlaceholderSearchEpisode": "Szukanie odcinka..", + "StatsAuthorsAdded": "dodano autorów", + "StatsBooksAdded": "dodano książki", + "StatsBooksFinished": "ukończone książki", + "StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…", + "StatsBooksListenedTo": "książki wysłuchane", + "StatsCollectionGrewTo": "Twoja kolekcja książek wzrosła do…", + "StatsSessions": "sesje", + "StatsSpentListening": "spędzono na słuchaniu", + "StatsTopAuthor": "TOPOWY AUTOR", + "StatsTopAuthors": "TOPOWI AUTORZY", + "StatsTopGenre": "TOPOWY GATUNEK", + "StatsTopGenres": "TOPOWE GATUNKI", + "StatsTopMonth": "TOPOWY MIESIĄC", + "StatsTopNarrator": "TOPOWY NARRATOR", + "StatsTopNarrators": "TOPOWI NARRATORZY", + "StatsYearInReview": "PRZEGLĄD ROKU", "ToastAccountUpdateFailed": "Nie udało się zaktualizować konta", "ToastAccountUpdateSuccess": "Zaktualizowano konto", "ToastAuthorImageRemoveFailed": "Nie udało się usunąć obrazu", From ac62d180079e0fc8b4e74de9d63feed529306057 Mon Sep 17 00:00:00 2001 From: Christian Wia Date: Sun, 18 Aug 2024 21:30:46 +0000 Subject: [PATCH 027/539] Translated using Weblate (French) Currently translated at 99.8% (874 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index 4c5d67a784..ef9ff2b842 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -46,6 +46,7 @@ "ButtonNevermind": "Non merci", "ButtonNext": "Suivant", "ButtonNextChapter": "Chapitre suivant", + "ButtonNextItemInQueue": "Elément suivant de la file d'attente", "ButtonOk": "Ok", "ButtonOpenFeed": "Ouvrir le flux", "ButtonOpenManager": "Ouvrir le gestionnaire", @@ -246,6 +247,7 @@ "LabelClosePlayer": "Fermer le lecteur", "LabelCodec": "Codec", "LabelCollapseSeries": "Réduire les séries", + "LabelCollapseSubSeries": "Replier les sous-séries", "LabelCollection": "Collection", "LabelCollections": "Collections", "LabelComplete": "Complet", @@ -298,6 +300,7 @@ "LabelEpisodeType": "Type de l’épisode", "LabelExample": "Exemple", "LabelExpandSeries": "Développer la série", + "LabelExpandSubSeries": "Développer les sous-séries", "LabelExplicit": "Restriction", "LabelExplicitChecked": "Explicite (vérifié)", "LabelExplicitUnchecked": "Non explicite (non vérifié)", From b3cefc075d283c959ebc26ca5fd811651a4e172c Mon Sep 17 00:00:00 2001 From: Tom Redd Date: Fri, 23 Aug 2024 12:43:54 +0000 Subject: [PATCH 028/539] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegi?= =?UTF-8?q?an=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 82.2% (720 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/ --- client/strings/no.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/strings/no.json b/client/strings/no.json index 6db2d98fb6..e2a7dbcae2 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -260,6 +260,7 @@ "LabelCurrently": "Nåværende:", "LabelCustomCronExpression": "Tilpasset Cron utrykk:", "LabelDatetime": "Dato tid", + "LabelDays": "Dager", "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beskrivelse", "LabelDeselectAll": "Fjern valg", @@ -290,6 +291,7 @@ "LabelEmbeddedCover": "Bak inn omslag", "LabelEnable": "Aktiver", "LabelEnd": "Slutt", + "LabelEndOfChapter": "Slutt på kapittel", "LabelEpisode": "Episode", "LabelEpisodeTitle": "Episode tittel", "LabelEpisodeType": "Episode type", @@ -297,6 +299,7 @@ "LabelExplicit": "Eksplisitt", "LabelExplicitChecked": "Explicit (checked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)", + "LabelExportOPML": "Eksporter OPML", "LabelFeedURL": "Feed Adresse", "LabelFetchingMetadata": "Fetching Metadata", "LabelFile": "Fil", @@ -320,9 +323,11 @@ "LabelHardDeleteFile": "Tving sletting av fil", "LabelHasEbook": "Har e-bok", "LabelHasSupplementaryEbook": "Har komplimentær e-bok", + "LabelHideSubtitles": "Skjul undertekster", "LabelHighestPriority": "Highest priority", "LabelHost": "Tjener", "LabelHour": "Time", + "LabelHours": "Timer", "LabelIcon": "Ikon", "LabelImageURLFromTheWeb": "Image URL from the web", "LabelInProgress": "I gang", @@ -412,6 +417,7 @@ "LabelOverwrite": "Overskriv", "LabelPassword": "Passord", "LabelPath": "Sti", + "LabelPermanent": "Fast", "LabelPermissionsAccessAllLibraries": "Har tilgang til alle bibliotek", "LabelPermissionsAccessAllTags": "Har til gang til alle tags", "LabelPermissionsAccessExplicitContent": "Har tilgang til eksplisitt material", @@ -509,6 +515,8 @@ "LabelSettingsStoreMetadataWithItem": "Lagre metadata med gjenstand", "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden", "LabelSettingsTimeFormat": "Tid format", + "LabelShare": "Dele", + "LabelShareURL": "Dele URL", "LabelShowAll": "Vis alt", "LabelShowSeconds": "Show seconds", "LabelSize": "Størrelse", @@ -548,6 +556,7 @@ "LabelThemeDark": "Mørk", "LabelThemeLight": "Lys", "LabelTimeBase": "Tidsbase", + "LabelTimeInMinutes": "Timer i minutter", "LabelTimeListened": "Tid lyttet", "LabelTimeListenedToday": "Tid lyttet idag", "LabelTimeRemaining": "{0} gjennstående", From 5383d0b5f794329ea251cf252a2880463412c2e9 Mon Sep 17 00:00:00 2001 From: biuklija Date: Mon, 26 Aug 2024 21:34:25 +0000 Subject: [PATCH 029/539] Translated using Weblate (Croatian) Currently translated at 65.6% (574 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 283 +++++++++++++++++++++-------------------- 1 file changed, 144 insertions(+), 139 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index ebe3207cfb..6b48e5c048 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -10,7 +10,7 @@ "ButtonApplyChapters": "Primijeni poglavlja", "ButtonAuthors": "Autori", "ButtonBack": "Natrag", - "ButtonBrowseForFolder": "Browse for Folder", + "ButtonBrowseForFolder": "Pronađi mapu", "ButtonCancel": "Odustani", "ButtonCancelEncode": "Otkaži kodiranje", "ButtonChangeRootPassword": "Promijeni Root lozinku", @@ -19,12 +19,12 @@ "ButtonChooseFiles": "Odaberi datoteke", "ButtonClearFilter": "Poništi filter", "ButtonCloseFeed": "Zatvori feed", - "ButtonCollections": "Kolekcije", - "ButtonConfigureScanner": "Configure Scanner", - "ButtonCreate": "Napravi", + "ButtonCollections": "Zbirke", + "ButtonConfigureScanner": "Postavi skener", + "ButtonCreate": "Izradi", "ButtonCreateBackup": "Napravi backup", - "ButtonDelete": "Obriši", - "ButtonDownloadQueue": "Queue", + "ButtonDelete": "Izbriši", + "ButtonDownloadQueue": "Redoslijed izvođenja", "ButtonEdit": "Uredi", "ButtonEditChapters": "Uredi poglavlja", "ButtonEditPodcast": "Uredi podcast", @@ -33,10 +33,10 @@ "ButtonHide": "Sakrij", "ButtonHome": "Početna stranica", "ButtonIssues": "Problemi", - "ButtonJumpBackward": "Jump Backward", - "ButtonJumpForward": "Jump Forward", + "ButtonJumpBackward": "Skok unatrag", + "ButtonJumpForward": "Skok unaprijed", "ButtonLatest": "Najnovije", - "ButtonLibrary": "Biblioteka", + "ButtonLibrary": "Knjižnica", "ButtonLogout": "Odjavi se", "ButtonLookup": "Potraži", "ButtonManageTracks": "Upravljanje pjesmama", @@ -45,31 +45,33 @@ "ButtonMatchBooks": "Matchaj knjige", "ButtonNevermind": "Nije bitno", "ButtonNext": "Next", - "ButtonNextChapter": "Next Chapter", + "ButtonNextChapter": "Sljedeće poglavlje", + "ButtonNextItemInQueue": "Sljedeća stavka u redoslijedu izvođenja", "ButtonOk": "Ok", - "ButtonOpenFeed": "Otvori feed", + "ButtonOpenFeed": "Otvori izvor", "ButtonOpenManager": "Otvori menadžera", - "ButtonPause": "Pause", + "ButtonPause": "Pauziraj", "ButtonPlay": "Pokreni", - "ButtonPlaying": "pušteno", - "ButtonPlaylists": "plejliste", - "ButtonPrevious": "Previous", - "ButtonPreviousChapter": "Previous Chapter", + "ButtonPlaying": "Izvodi se", + "ButtonPlaylists": "Popisi za izvođenje", + "ButtonPrevious": "Prethodno", + "ButtonPreviousChapter": "Prethodno poglavlje", "ButtonPurgeAllCache": "Isprazni sav cache", "ButtonPurgeItemsCache": "Isprazni Items Cache", - "ButtonQueueAddItem": "Add to queue", - "ButtonQueueRemoveItem": "Remove from queue", + "ButtonQueueAddItem": "Dodaj na redoslijed izvođenja", + "ButtonQueueRemoveItem": "Ukloni s redoslijeda izvođenja", + "ButtonQuickEmbedMetadata": "Brzo ugrađivanje meta-podataka", "ButtonQuickMatch": "Brzi match", "ButtonReScan": "Skeniraj ponovno", "ButtonRead": "Pročitaj", - "ButtonReadLess": "Read less", - "ButtonReadMore": "Read more", - "ButtonRefresh": "Refresh", + "ButtonReadLess": "Pročitaj manje", + "ButtonReadMore": "Pročitaj više", + "ButtonRefresh": "Osvježi", "ButtonRemove": "Ukloni", "ButtonRemoveAll": "Ukloni sve", "ButtonRemoveAllLibraryItems": "Ukloni sve stvari iz biblioteke", "ButtonRemoveFromContinueListening": "Ukloni iz Nastavi slušati", - "ButtonRemoveFromContinueReading": "Remove from Continue Reading", + "ButtonRemoveFromContinueReading": "Ukloni s popisa Nastavi", "ButtonRemoveSeriesFromContinueSeries": "Ukloni seriju iz Nastavi seriju", "ButtonReset": "Poništi", "ButtonResetToDefault": "Reset to default", @@ -78,61 +80,62 @@ "ButtonSaveAndClose": "Spremi i zatvori", "ButtonSaveTracklist": "Spremi popis pjesama", "ButtonScan": "Skeniraj", - "ButtonScanLibrary": "Scan Library", + "ButtonScanLibrary": "Skeniraj biblioteku", "ButtonSearch": "Traži", "ButtonSelectFolderPath": "Odaberi putanju do folder", - "ButtonSeries": "Serije", - "ButtonSetChaptersFromTracks": "Set chapters from tracks", - "ButtonShare": "Share", + "ButtonSeries": "Serijali", + "ButtonSetChaptersFromTracks": "Postavi poglavlja iz datoteka", + "ButtonShare": "Podijeli", "ButtonShiftTimes": "Pomakni vremena", "ButtonShow": "Prikaži", "ButtonStartM4BEncode": "Pokreni M4B kodiranje", "ButtonStartMetadataEmbed": "Pokreni ugradnju metapodataka", - "ButtonSubmit": "Submit", + "ButtonStats": "Statistika", + "ButtonSubmit": "Podnesi", "ButtonTest": "Test", "ButtonUpload": "Upload", "ButtonUploadBackup": "Upload backup", - "ButtonUploadCover": "Upload Cover", + "ButtonUploadCover": "Učitaj naslovnicu", "ButtonUploadOPMLFile": "Upload OPML Datoteku", "ButtonUserDelete": "Delete user {0}", - "ButtonUserEdit": "Edit user {0}", + "ButtonUserEdit": "Uredi korisnika {0}", "ButtonViewAll": "Prikaži sve", "ButtonYes": "Da", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "Pogreška pri dohvatu metapodataka", + "ErrorUploadFetchMetadataNoResults": "Dohvat metapodataka nije uspio - pokušajte ispraviti naslov i/ili autora", + "ErrorUploadLacksTitle": "Naslov je obavezan", "HeaderAccount": "Korisnički račun", "HeaderAdvanced": "Napredno", - "HeaderAppriseNotificationSettings": "Apprise Notification Settings", - "HeaderAudioTracks": "audio trake", + "HeaderAppriseNotificationSettings": "Postavke obavijesti Apprise", + "HeaderAudioTracks": "Zvučni zapisi", "HeaderAudiobookTools": "Audiobook File Management alati", "HeaderAuthentication": "Authentication", - "HeaderBackups": "Backups", + "HeaderBackups": "Sigurnosne kopije", "HeaderChangePassword": "Promijeni lozinku", "HeaderChapters": "Poglavlja", "HeaderChooseAFolder": "Odaberi folder", - "HeaderCollection": "Kolekcija", - "HeaderCollectionItems": "Stvari u kolekciji", - "HeaderCover": "Cover", - "HeaderCurrentDownloads": "Current Downloads", - "HeaderCustomMessageOnLogin": "Custom Message on Login", - "HeaderCustomMetadataProviders": "Custom Metadata Providers", - "HeaderDetails": "Detalji", - "HeaderDownloadQueue": "Download Queue", - "HeaderEbookFiles": "fajlovi elektronske knjige", + "HeaderCollection": "Zbirka", + "HeaderCollectionItems": "Stavke u zbirci", + "HeaderCover": "Naslovnica", + "HeaderCurrentDownloads": "Preuzimanja u tijeku", + "HeaderCustomMessageOnLogin": "Prilagođena poruka prilikom prijave", + "HeaderCustomMetadataProviders": "Prilagođeni pružatelji metapodataka", + "HeaderDetails": "Pojedinosti", + "HeaderDownloadQueue": "Redoslijed preuzimanja", + "HeaderEbookFiles": "Datoteke e-knjiga", "HeaderEmail": "Email", - "HeaderEmailSettings": "Email Settings", - "HeaderEpisodes": "Epizode", - "HeaderEreaderDevices": "Ereader Devices", - "HeaderEreaderSettings": "podešavanje elektronskog čitača", + "HeaderEmailSettings": "Postavke e-pošte", + "HeaderEpisodes": "Nastavci", + "HeaderEreaderDevices": "E-čitači", + "HeaderEreaderSettings": "Postavke e-čitača", "HeaderFiles": "Datoteke", "HeaderFindChapters": "Pronađi poglavlja", "HeaderIgnoredFiles": "Zanemarene datoteke", "HeaderItemFiles": "Item Files", "HeaderItemMetadataUtils": "Item Metadata Utils", "HeaderLastListeningSession": "Posljednja Listening Session", - "HeaderLatestEpisodes": "Najnovije epizode", - "HeaderLibraries": "Biblioteke", + "HeaderLatestEpisodes": "Najnoviji nastavci", + "HeaderLibraries": "Knjižnice", "HeaderLibraryFiles": "Library Files", "HeaderLibraryStats": "Library Stats", "HeaderListeningSessions": "Listening Sessions", @@ -149,17 +152,17 @@ "HeaderNewLibrary": "Nova biblioteka", "HeaderNotifications": "Obavijesti", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", - "HeaderOpenRSSFeed": "Otvori RSS Feed", + "HeaderOpenRSSFeed": "Otvori RSS izvor", "HeaderOtherFiles": "Druge datoteke", "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Dozvole", "HeaderPlayerQueue": "Player Queue", - "HeaderPlaylist": "Playlist", - "HeaderPlaylistItems": "Playlist Items", + "HeaderPlaylist": "Popis za izvođenje", + "HeaderPlaylistItems": "Stavke popisa za izvođenje", "HeaderPodcastsToAdd": "Podcasti za dodati", "HeaderPreviewCover": "Pregledaj Cover", - "HeaderRSSFeedGeneral": "RSS Details", - "HeaderRSSFeedIsOpen": "RSS Feed je otvoren", + "HeaderRSSFeedGeneral": "RSS pojedinosti", + "HeaderRSSFeedIsOpen": "RSS izvor je otvoren", "HeaderRSSFeeds": "RSS Feeds", "HeaderRemoveEpisode": "Ukloni epizodu", "HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e", @@ -173,14 +176,14 @@ "HeaderSettingsExperimental": "Eksperimentalni Features", "HeaderSettingsGeneral": "Opčenito", "HeaderSettingsScanner": "Scanner", - "HeaderSleepTimer": "merač vremena spavanja", + "HeaderSleepTimer": "Timer za spavanje", "HeaderStatsLargestItems": "Largest Items", "HeaderStatsLongestItems": "Najduže stavke (sati)", - "HeaderStatsMinutesListeningChart": "Minuta odslušanih (posljednjih 7 dana)", + "HeaderStatsMinutesListeningChart": "Odslušanih minuta (posljednjih 7 dana)", "HeaderStatsRecentSessions": "Nedavne sesije", "HeaderStatsTop10Authors": "Top 10 autora", "HeaderStatsTop5Genres": "Top 5 žanrova", - "HeaderTableOfContents": "tabela kontenta", + "HeaderTableOfContents": "Sadržaj", "HeaderTools": "Alati", "HeaderUpdateAccount": "Aktualiziraj Korisnički račun", "HeaderUpdateAuthor": "Aktualiziraj autora", @@ -188,7 +191,7 @@ "HeaderUpdateLibrary": "Aktualiziraj biblioteku", "HeaderUsers": "Korinici", "HeaderYearReview": "Year {0} in Review", - "HeaderYourStats": "Tvoja statistika", + "HeaderYourStats": "Vaša statistika", "LabelAbridged": "Abridged", "LabelAbridgedChecked": "Abridged (checked)", "LabelAbridgedUnchecked": "Unabridged (unchecked)", @@ -200,22 +203,22 @@ "LabelActivity": "Aktivnost", "LabelAddToCollection": "Dodaj u kolekciju", "LabelAddToCollectionBatch": "Add {0} Books to Collection", - "LabelAddToPlaylist": "Add to Playlist", + "LabelAddToPlaylist": "Dodaj na popis za izvođenje", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", - "LabelAdded": "dodato", - "LabelAddedAt": "dodato u", + "LabelAdded": "Dodano", + "LabelAddedAt": "Dodano u", "LabelAdminUsersOnly": "Admin users only", - "LabelAll": "sve", + "LabelAll": "Sve", "LabelAllUsers": "Svi korisnici", "LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Autor", - "LabelAuthorFirstLast": "autor (prvi zadnji)", - "LabelAuthorLastFirst": "autor (zadnji, prvi)", + "LabelAuthorFirstLast": "Autor (Ime Prezime)", + "LabelAuthorLastFirst": "Autor (Prezime, Ime)", "LabelAuthors": "Autori", - "LabelAutoDownloadEpisodes": "Automatski preuzmi epizode", + "LabelAutoDownloadEpisodes": "Automatski preuzmi nastavke", "LabelAutoFetchMetadata": "Auto Fetch Metadata", "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", "LabelAutoLaunch": "Auto Launch", @@ -237,19 +240,19 @@ "LabelChangePassword": "Promijeni lozinku", "LabelChannels": "Channels", "LabelChapterTitle": "Ime poglavlja", - "LabelChapters": "poglavlja", + "LabelChapters": "Poglavlja", "LabelChaptersFound": "poglavlja pronađena", "LabelClickForMoreInfo": "Click for more info", - "LabelClosePlayer": "izaberi igrača", + "LabelClosePlayer": "Zatvori izvođač", "LabelCodec": "Codec", - "LabelCollapseSeries": "Collapse Series", + "LabelCollapseSeries": "Serijal prikaži skraćeno", "LabelCollection": "Collection", "LabelCollections": "Kolekcije", - "LabelComplete": "završeno", + "LabelComplete": "Dovršeno", "LabelConfirmPassword": "Potvrdi lozinku", - "LabelContinueListening": "nastavi slušati", - "LabelContinueReading": "nastavi čitati", - "LabelContinueSeries": "nastavi serije", + "LabelContinueListening": "Nastavi slušati", + "LabelContinueReading": "Nastavi čitati", + "LabelContinueSeries": "Nastavi serijal", "LabelCover": "Cover", "LabelCoverImageURL": "URL od covera", "LabelCreatedAt": "Napravljeno", @@ -267,7 +270,7 @@ "LabelDirectory": "Direktorij", "LabelDiscFromFilename": "CD iz imena datoteke", "LabelDiscFromMetadata": "CD iz metapodataka", - "LabelDiscover": "otkriti", + "LabelDiscover": "Otkrij", "LabelDownload": "Preuzmi", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Trajanje", @@ -275,8 +278,8 @@ "LabelDurationComparisonLonger": "({0} longer)", "LabelDurationComparisonShorter": "({0} shorter)", "LabelDurationFound": "Pronađeno trajanje:", - "LabelEbook": "elektronska knjiga", - "LabelEbooks": "elektronske knjige", + "LabelEbook": "Elektronička knjiga", + "LabelEbooks": "Elektroničke knjige", "LabelEdit": "Uredi", "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "From Address", @@ -286,40 +289,41 @@ "LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test Address", "LabelEmbeddedCover": "Embedded Cover", - "LabelEnable": "Uključi", + "LabelEnable": "Omogući", "LabelEnd": "Kraj", - "LabelEpisode": "Epizoda", + "LabelEndOfChapter": "Kraj poglavlja", + "LabelEpisode": "Nastavak", "LabelEpisodeTitle": "Naslov epizode", "LabelEpisodeType": "Vrsta epizode", "LabelExample": "Example", "LabelExplicit": "Explicit", "LabelExplicitChecked": "Explicit (checked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)", - "LabelFeedURL": "Feed URL", + "LabelFeedURL": "URL izvora", "LabelFetchingMetadata": "Fetching Metadata", "LabelFile": "Datoteka", - "LabelFileBirthtime": "File Birthtime", - "LabelFileModified": "fajl izmenjen", - "LabelFilename": "Ime datoteke", + "LabelFileBirthtime": "Vrijeme stvaranja datoteke", + "LabelFileModified": "Datoteka izmijenjena", + "LabelFilename": "Naziv datoteke", "LabelFilterByUser": "Filtriraj po korisniku", "LabelFindEpisodes": "Pronađi epizode", - "LabelFinished": "završen", - "LabelFolder": "folder", + "LabelFinished": "Dovršeno", + "LabelFolder": "Mapa", "LabelFolders": "Folderi", "LabelFontBold": "Bold", - "LabelFontBoldness": "Font Boldness", + "LabelFontBoldness": "Debljina slova", "LabelFontFamily": "Font family", "LabelFontItalic": "Italic", - "LabelFontScale": "Font scale", + "LabelFontScale": "Veličina slova", "LabelFontStrikethrough": "Strikethrough", "LabelFormat": "Format", - "LabelGenre": "Genre", + "LabelGenre": "Žanr", "LabelGenres": "Žanrovi", "LabelHardDeleteFile": "Obriši datoteku zauvijek", - "LabelHasEbook": "Has ebook", - "LabelHasSupplementaryEbook": "Has supplementary ebook", + "LabelHasEbook": "Ima e-knjigu", + "LabelHasSupplementaryEbook": "Ima dopunsku e-knjigu", "LabelHighestPriority": "Highest priority", - "LabelHost": "Host", + "LabelHost": "Poslužitelj", "LabelHour": "Sat", "LabelIcon": "Ikona", "LabelImageURLFromTheWeb": "Image URL from the web", @@ -345,8 +349,8 @@ "LabelLastSeen": "Zadnje pogledano", "LabelLastTime": "Prošli put", "LabelLastUpdate": "Zadnja aktualizacija", - "LabelLayout": "Layout", - "LabelLayoutSinglePage": "Single page", + "LabelLayout": "Prikaz", + "LabelLayoutSinglePage": "Jedna stranica", "LabelLayoutSplitPage": "Split page", "LabelLess": "Manje", "LabelLibrariesAccessibleToUser": "Biblioteke pristupačne korisniku", @@ -355,8 +359,8 @@ "LabelLibraryItem": "Stavka biblioteke", "LabelLibraryName": "Ime biblioteke", "LabelLimit": "Limit", - "LabelLineSpacing": "Line spacing", - "LabelListenAgain": "Slušaj ponovno", + "LabelLineSpacing": "Razmak između redaka", + "LabelListenAgain": "Ponovno poslušaj", "LabelLogLevelDebug": "Debug", "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", @@ -365,7 +369,7 @@ "LabelMatchExistingUsersBy": "Match existing users by", "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Media Player", - "LabelMediaType": "Media Type", + "LabelMediaType": "Vrsta medijskog zapisa", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", @@ -377,20 +381,20 @@ "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "Više", - "LabelMoreInfo": "More Info", + "LabelMoreInfo": "Više informacija", "LabelName": "Ime", - "LabelNarrator": "Narrator", - "LabelNarrators": "Naratori", + "LabelNarrator": "Pripovjedač", + "LabelNarrators": "Pripovjedači", "LabelNew": "Novo", "LabelNewPassword": "Nova lozinka", "LabelNewestAuthors": "Najnoviji autori", - "LabelNewestEpisodes": "Najnovije epizode", + "LabelNewestEpisodes": "Najnoviji nastavci", "LabelNextBackupDate": "Next backup date", "LabelNextScheduledRun": "Next scheduled run", "LabelNoCustomMetadataProviders": "No custom metadata providers", "LabelNoEpisodesSelected": "No episodes selected", - "LabelNotFinished": "Nedovršeno", - "LabelNotStarted": "Not Started", + "LabelNotFinished": "Nije dovršeno", + "LabelNotStarted": "Nije započeto", "LabelNotes": "Bilješke", "LabelNotificationAppriseURL": "Apprise URL(s)", "LabelNotificationAvailableVariables": "Dostupne varijable", @@ -408,7 +412,7 @@ "LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as groups. If configured, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.", "LabelOpenRSSFeed": "Otvori RSS Feed", "LabelOverwrite": "Overwrite", - "LabelPassword": "Lozinka", + "LabelPassword": "Zaporka", "LabelPath": "Putanja", "LabelPermissionsAccessAllLibraries": "Ima pristup svim bibliotekama", "LabelPermissionsAccessAllTags": "Ima pristup svim tagovima", @@ -425,27 +429,28 @@ "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Područje pretrage podcasta", "LabelPodcastType": "Podcast Type", - "LabelPodcasts": "Podcasts", + "LabelPodcasts": "Podcasti", "LabelPort": "Port", "LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)", - "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", + "LabelPreventIndexing": "Onemogućite da iTunes i Google indeksiraju vaš feed za svoje popise podcasta", "LabelPrimaryEbook": "Primary ebook", "LabelProgress": "Napredak", "LabelProvider": "Dobavljač", - "LabelPubDate": "Datam izdavanja", + "LabelPubDate": "Datum izdavanja", "LabelPublishYear": "Godina izdavanja", "LabelPublisher": "Izdavač", "LabelPublishers": "Publishers", - "LabelRSSFeedCustomOwnerEmail": "Custom owner Email", - "LabelRSSFeedCustomOwnerName": "Custom owner Name", + "LabelRSSFeedCustomOwnerEmail": "Prilagođena adresa e-pošte vlasnika", + "LabelRSSFeedCustomOwnerName": "Prilagođeno ime vlasnika", "LabelRSSFeedOpen": "RSS Feed Open", - "LabelRSSFeedPreventIndexing": "Prevent Indexing", - "LabelRSSFeedSlug": "RSS Feed Slug", + "LabelRSSFeedPreventIndexing": "Onemogući indeksiranje", + "LabelRSSFeedSlug": "Slug RSS izvora", "LabelRSSFeedURL": "RSS Feed URL", - "LabelRead": "Read", - "LabelReadAgain": "Read Again", + "LabelRandomly": "Nasumično", + "LabelRead": "Čitaj", + "LabelReadAgain": "Ponovno čitaj", "LabelReadEbookWithoutProgress": "Read ebook without keeping progress", - "LabelRecentSeries": "Nedavne serije", + "LabelRecentSeries": "Nedavni serijal", "LabelRecentlyAdded": "Nedavno dodano", "LabelRecommended": "Recommended", "LabelRedo": "Redo", @@ -463,12 +468,12 @@ "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sekvenca", - "LabelSeries": "Serije", + "LabelSeries": "Serijali", "LabelSeriesName": "Ime serije", "LabelSeriesProgress": "Series Progress", "LabelServerYearReview": "Server Year in Review ({0})", - "LabelSetEbookAsPrimary": "Set as primary", - "LabelSetEbookAsSupplementary": "Set as supplementary", + "LabelSetEbookAsPrimary": "Postavi kao primarno", + "LabelSetEbookAsSupplementary": "Postavi kao dopunsko", "LabelSettingsAudiobooksOnly": "Audiobooks only", "LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks", "LabelSettingsBookshelfViewHelp": "Skeumorfski (što god to bilo) dizajn sa drvenim policama", @@ -510,7 +515,7 @@ "LabelShowAll": "Prikaži sve", "LabelShowSeconds": "Show seconds", "LabelSize": "Veličina", - "LabelSleepTimer": "Sleep timer", + "LabelSleepTimer": "Timer za spavanje", "LabelSlug": "Slug", "LabelStart": "Pokreni", "LabelStartTime": "Vrijeme pokretanja", @@ -521,10 +526,10 @@ "LabelStatsBestDay": "Najbolji dan", "LabelStatsDailyAverage": "Dnevni prosjek", "LabelStatsDays": "Dani", - "LabelStatsDaysListened": "Dana slušao", + "LabelStatsDaysListened": "Dana slušano", "LabelStatsHours": "Sati", - "LabelStatsInARow": "u redu", - "LabelStatsItemsFinished": "Završenih stavki", + "LabelStatsInARow": "uzastopno", + "LabelStatsItemsFinished": "Dovršenih stavki", "LabelStatsItemsInLibrary": "Stavke u biblioteki", "LabelStatsMinutes": "minute", "LabelStatsMinutesListening": "Minuta odslušano", @@ -533,8 +538,8 @@ "LabelStatsWeekListening": "Tjedno slušanje", "LabelSubtitle": "Podnapis", "LabelSupportedFileTypes": "Podržtani tip datoteke", - "LabelTag": "Tag", - "LabelTags": "Tags", + "LabelTag": "Oznaka", + "LabelTags": "Oznake", "LabelTagsAccessibleToUser": "Tags dostupni korisniku", "LabelTagsNotAccessibleToUser": "Tags not Accessible to User", "LabelTasks": "Tasks Running", @@ -542,9 +547,9 @@ "LabelTextEditorLink": "Link", "LabelTextEditorNumberedList": "Numbered list", "LabelTextEditorUnlink": "Unlink", - "LabelTheme": "Theme", - "LabelThemeDark": "Dark", - "LabelThemeLight": "Light", + "LabelTheme": "Tema", + "LabelThemeDark": "Tamna", + "LabelThemeLight": "Svijetla", "LabelTimeBase": "Time Base", "LabelTimeListened": "Vremena odslušano", "LabelTimeListenedToday": "Vremena odslušano danas", @@ -561,11 +566,11 @@ "LabelTotalTimeListened": "Sveukupno vrijeme slušanja", "LabelTrackFromFilename": "Track iz imena datoteke", "LabelTrackFromMetadata": "Track iz metapodataka", - "LabelTracks": "Tracks", + "LabelTracks": "Zapisi", "LabelTracksMultiTrack": "Multi-track", "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Single-track", - "LabelType": "Tip", + "LabelType": "Vrsta", "LabelUnabridged": "Unabridged", "LabelUndo": "Undo", "LabelUnknown": "Nepoznato", @@ -591,9 +596,9 @@ "LabelYearReviewHide": "Hide Year in Review", "LabelYearReviewShow": "See Year in Review", "LabelYourAudiobookDuration": "Tvoje trajanje audiobooka", - "LabelYourBookmarks": "Tvoje knjižne oznake", + "LabelYourBookmarks": "Vaše knjižne oznake", "LabelYourPlaylists": "Your Playlists", - "LabelYourProgress": "Tvoj napredak", + "LabelYourProgress": "Vaš napredak", "MessageAddToPlayerQueue": "Add to player queue", "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Backups uključuju korisnike, korisnikov napredak, detalje stavki iz biblioteke, postavke server i slike iz /metadata/items & /metadata/authors. Backups ne uključuju nijedne datoteke koje su u folderima biblioteke.", @@ -640,13 +645,13 @@ "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", - "MessageDownloadingEpisode": "Preuzimam epizodu", + "MessageDownloadingEpisode": "Preuzimam nastavak", "MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.", "MessageEmbedFinished": "Embed završen!", - "MessageEpisodesQueuedForDownload": "{0} Epizoda/-e u redu za preuzimanje", + "MessageEpisodesQueuedForDownload": "{0} nastavak(a) u redu za preuzimanje", "MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.", - "MessageFeedURLWillBe": "Feed URL će biti {0}", - "MessageFetching": "Dobavljam...", + "MessageFeedURLWillBe": "URL izvora bit će {0}", + "MessageFetching": "Dohvaćam...", "MessageForceReScanDescription": "će skenirati sve datoteke ponovno kao svježi sken. ID3 tagovi od audio datoteka, OPF datoteke i tekst datoteke će biti skenirane kao da su nove.", "MessageImportantNotice": "Važna obavijest!", "MessageInsertChapterBelow": "Unesi poglavlje ispod", @@ -662,13 +667,13 @@ "MessageMapChapterTitles": "Mapiraj imena poglavlja u postoječa poglavlja bez izmijene timestampova.", "MessageMarkAllEpisodesFinished": "Mark all episodes finished", "MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished", - "MessageMarkAsFinished": "Označi kao završeno", + "MessageMarkAsFinished": "Označi kao dovršeno", "MessageMarkAsNotFinished": "Označi kao nezavršeno", "MessageMatchBooksDescription": "će probati matchati knjige iz biblioteke sa knjigom od odabranog poslužitelja i popuniti prazne detalje i cover. Ne briše postojeće detalje.", "MessageNoAudioTracks": "Nema audio tracks", "MessageNoAuthors": "Nema autora", "MessageNoBackups": "Nema backupa", - "MessageNoBookmarks": "Nema knjižnih bilješki", + "MessageNoBookmarks": "Nema knjižnih oznaka", "MessageNoChapters": "Nema poglavlja", "MessageNoCollections": "Nema kolekcija", "MessageNoCoversFound": "Covers nisu pronađeni", @@ -681,20 +686,20 @@ "MessageNoGenres": "Nema žanrova", "MessageNoIssues": "No Issues", "MessageNoItems": "Nema stavki", - "MessageNoItemsFound": "Nijedna stavka pronađena", - "MessageNoListeningSessions": "Nema Listening Sessions", + "MessageNoItemsFound": "Nema pronađenih stavki", + "MessageNoListeningSessions": "Nema sesija slušanja", "MessageNoLogs": "Nema Logs", "MessageNoMediaProgress": "Nema Media napredka", "MessageNoNotifications": "Nema obavijesti", - "MessageNoPodcastsFound": "Nijedan podcast pronađen", + "MessageNoPodcastsFound": "Podcasti nisu pronađeni", "MessageNoResults": "Nema rezultata", "MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"", "MessageNoSeries": "No Series", "MessageNoTags": "No Tags", "MessageNoTasksRunning": "No Tasks Running", "MessageNoUpdateNecessary": "Aktualiziranje nije potrebno", - "MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno", - "MessageNoUserPlaylists": "You have no playlists", + "MessageNoUpdatesWereNecessary": "Ažuriranje nije bilo potrebno", + "MessageNoUserPlaylists": "Nemate popisa za izvođenje", "MessageNotYetImplemented": "Not yet implemented", "MessageOr": "or", "MessagePauseChapter": "Pause chapter playback", From 6ad07198800a18dda973f38db5f620cbeca7882f Mon Sep 17 00:00:00 2001 From: biuklija Date: Mon, 26 Aug 2024 22:02:25 +0000 Subject: [PATCH 030/539] Translated using Weblate (Croatian) Currently translated at 65.6% (574 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 6b48e5c048..f8a36f8cdf 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -711,7 +711,7 @@ "MessageRemoveEpisodes": "ukloni {0} epizoda/-e", "MessageRemoveFromPlayerQueue": "Remove from player queue", "MessageRemoveUserWarning": "Jeste li sigurni da želite trajno obrisati korisnika \"{0}\"?", - "MessageReportBugsAndContribute": "Prijavte bugove, zatržite featurese i doprinosite na", + "MessageReportBugsAndContribute": "Prijavite pogreške, zatražite osobine i doprinosite na adresi", "MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?", "MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran", "MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.

Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.

Svi klijenti koji koriste tvoj server će biti automatski osvježeni.", From a92e4175813c716ffca8b49d71e0b3b68ef437ab Mon Sep 17 00:00:00 2001 From: biuklija Date: Mon, 26 Aug 2024 22:07:45 +0000 Subject: [PATCH 031/539] Translated using Weblate (Croatian) Currently translated at 65.6% (574 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index f8a36f8cdf..2e1bf46a56 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -732,8 +732,8 @@ "NoteChangeRootPassword": "Root korisnik je jedini korisnik koji može imati praznu lozinku", "NoteChapterEditorTimes": "Bilješka: Prvo početno vrijeme poglavlja mora ostati na 0:00 i posljednje vrijeme poglavlja ne smije preći vrijeme trajanja ove audio knjige.", "NoteFolderPicker": "Bilješka: več mapirani folderi neće biti prikazani", - "NoteRSSFeedPodcastAppsHttps": "Upozorenje: Večina podcasta će trebati RSS feed URL koji koristi HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "Upozorenje: 1 ili više vaših epizoda nemaju datum objavljivanja. Neke podcast aplikacije zahtjevaju to.", + "NoteRSSFeedPodcastAppsHttps": "Upozorenje: Za većinu podcasta trebat će vam RSS izvor koji se koristi HTTPS-om. feed URL koji koristi HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Upozorenje: 1 ili više vaših nastavaka nemaju datum objavljivanja. To je obavezno kod nekih aplikacija za podcaste.", "NoteUploaderFoldersWithMediaFiles": "Folderi sa media datotekama će biti tretirane kao odvojene stavke u biblioteki.", "NoteUploaderOnlyAudioFiles": "Ako uploadate samo audio datoteke onda će audio datoteka biti tretirana kao odvojena audioknjiga.", "NoteUploaderUnsupportedFiles": "Nepodržane datoteke su ignorirane. Kada birate ili ubacujete folder, ostale datoteke koje nisu folder će biti ignorirane.", From df1c157994ba90a32765eb7775132d4142d5dad3 Mon Sep 17 00:00:00 2001 From: biuklija Date: Tue, 27 Aug 2024 11:30:15 +0000 Subject: [PATCH 032/539] Translated using Weblate (Croatian) Currently translated at 65.8% (576 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 2e1bf46a56..ea52e6af9a 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -132,7 +132,7 @@ "HeaderFindChapters": "Pronađi poglavlja", "HeaderIgnoredFiles": "Zanemarene datoteke", "HeaderItemFiles": "Item Files", - "HeaderItemMetadataUtils": "Item Metadata Utils", + "HeaderItemMetadataUtils": "Alati za metapodatke", "HeaderLastListeningSession": "Posljednja Listening Session", "HeaderLatestEpisodes": "Najnoviji nastavci", "HeaderLibraries": "Knjižnice", @@ -759,11 +759,11 @@ "ToastBackupUploadSuccess": "Backup uploadan", "ToastBatchUpdateFailed": "Batch update neuspješan", "ToastBatchUpdateSuccess": "Batch update uspješan", - "ToastBookmarkCreateFailed": "Kreiranje knjižne bilješke neuspješno", + "ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela", "ToastBookmarkCreateSuccess": "Knjižna bilješka dodana", - "ToastBookmarkRemoveFailed": "Brisanje knjižne bilješke nauspješno", + "ToastBookmarkRemoveFailed": "Brisanje knjižne bilješke nije uspjelo", "ToastBookmarkRemoveSuccess": "Knjižnja bilješka uklonjena", - "ToastBookmarkUpdateFailed": "Aktualizacija knjižne bilješke neuspješna", + "ToastBookmarkUpdateFailed": "Ažuriranje knjižne bilješke nije uspjelo", "ToastBookmarkUpdateSuccess": "Knjižna bilješka aktualizirana", "ToastCachePurgeFailed": "Failed to purge cache", "ToastCachePurgeSuccess": "Cache purged successfully", @@ -783,9 +783,9 @@ "ToastItemDetailsUpdateFailed": "Aktualiziranje detalja stavke neuspješno", "ToastItemDetailsUpdateSuccess": "Detalji stavke aktualizirani", "ToastItemDetailsUpdateUnneeded": "Aktualiziranje detalja stavke nepotrebno", - "ToastItemMarkedAsFinishedFailed": "Označi kao Završeno neuspješno", + "ToastItemMarkedAsFinishedFailed": "Označavanje kao Dovršeno nije uspjelo", "ToastItemMarkedAsFinishedSuccess": "Stavka označena kao Završeno", - "ToastItemMarkedAsNotFinishedFailed": "Označi kao Nezavršeno neuspješno", + "ToastItemMarkedAsNotFinishedFailed": "Označavanje kao Nije dovršeno nije uspjelo", "ToastItemMarkedAsNotFinishedSuccess": "Stavka oznaečena kao Nezavršeno", "ToastLibraryCreateFailed": "Kreiranje biblioteke neuspješno", "ToastLibraryCreateSuccess": "Biblioteka \"{0}\" kreirana", @@ -795,16 +795,16 @@ "ToastLibraryScanStarted": "Sken biblioteke pokrenut", "ToastLibraryUpdateFailed": "Aktualiziranje biblioteke neuspješno", "ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" aktualizirana", - "ToastPlaylistCreateFailed": "Failed to create playlist", + "ToastPlaylistCreateFailed": "Popis za izvođenje nije izrađen", "ToastPlaylistCreateSuccess": "Playlist created", "ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateSuccess": "Playlist updated", - "ToastPodcastCreateFailed": "Neuspješno kreiranje podcasta", - "ToastPodcastCreateSuccess": "Podcast uspješno kreiran", - "ToastRSSFeedCloseFailed": "Neuspješno zatvaranje RSS Feeda", - "ToastRSSFeedCloseSuccess": "RSS Feed zatvoren", + "ToastPodcastCreateFailed": "Podcast nije izrađen", + "ToastPodcastCreateSuccess": "Podcast uspješno izrađen", + "ToastRSSFeedCloseFailed": "RSS izvor nije uspješno zatvoren", + "ToastRSSFeedCloseSuccess": "RSS izvor zatvoren", "ToastRemoveItemFromCollectionFailed": "Neuspješno uklanjanje stavke iz kolekcije", "ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz kolekcije", "ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device", From c45c82306e1f3cafa9e41d1ca9c4c94f60aec602 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 28 Aug 2024 17:26:23 -0500 Subject: [PATCH 033/539] Remove old library, folder and librarysettings model --- server/Watcher.js | 2 +- server/controllers/LibraryController.js | 20 ++-- server/managers/CronManager.js | 8 +- server/models/Library.js | 129 +-------------------- server/objects/Folder.js | 38 ------ server/objects/Library.js | 95 --------------- server/objects/settings/LibrarySettings.js | 73 ------------ server/scanner/LibraryScan.js | 38 +++--- server/scanner/LibraryScanner.js | 27 +++-- server/scanner/Scanner.js | 78 ++++++++----- server/utils/migrations/dbMigration.js | 12 +- 11 files changed, 115 insertions(+), 405 deletions(-) delete mode 100644 server/objects/Folder.js delete mode 100644 server/objects/Library.js delete mode 100644 server/objects/settings/LibrarySettings.js diff --git a/server/Watcher.js b/server/Watcher.js index 32788e27db..83c45234c9 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -19,7 +19,7 @@ class FolderWatcher extends EventEmitter { constructor() { super() - /** @type {{id:string, name:string, folders:import('./objects/Folder')[], paths:string[], watcher:Watcher[]}[]} */ + /** @type {{id:string, name:string, libraryFolders:import('./models/Folder')[], paths:string[], watcher:Watcher[]}[]} */ this.libraryWatchers = [] /** @type {PendingFileUpdate[]} */ this.pendingFileUpdates = [] diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 9d9ed2eecb..59e8c181d7 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -172,17 +172,17 @@ class LibraryController { * @param {Response} res */ async findAll(req, res) { - const libraries = await Database.libraryModel.getAllOldLibraries() + const libraries = await Database.libraryModel.getAllWithFolders() const librariesAccessible = req.user.permissions?.librariesAccessible || [] if (librariesAccessible.length) { return res.json({ - libraries: libraries.filter((lib) => librariesAccessible.includes(lib.id)).map((lib) => lib.toJSON()) + libraries: libraries.filter((lib) => librariesAccessible.includes(lib.id)).map((lib) => lib.toOldJSON()) }) } res.json({ - libraries: libraries.map((lib) => lib.toJSON()) + libraries: libraries.map((lib) => lib.toOldJSON()) }) } @@ -198,16 +198,15 @@ class LibraryController { const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id) const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType) - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) return res.json({ filterdata, issues: filterdata.numIssues, numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id), customMetadataProviders, - library: oldLibrary + library: req.library.toOldJSON() }) } - res.json(oldLibrary) + res.json(req.library.toOldJSON()) } /** @@ -1051,9 +1050,7 @@ class LibraryController { Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`) return res.sendStatus(403) } - // TODO: Update to new library model - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) - Scanner.matchLibraryItems(oldLibrary) + Scanner.matchLibraryItems(req.library) res.sendStatus(200) } @@ -1071,10 +1068,9 @@ class LibraryController { return res.sendStatus(403) } res.sendStatus(200) - // TODO: Update to new library model - const oldLibrary = Database.libraryModel.getOldLibrary(req.library) + const forceRescan = req.query.force === '1' - await LibraryScanner.scan(oldLibrary, forceRescan) + await LibraryScanner.scan(req.library, forceRescan) await Database.resetLibraryIssuesFilterData(req.library.id) Logger.info('[LibraryController] Scan complete') diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index 09ff4d95e3..7a8c9bd0e3 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -65,12 +65,12 @@ class CronManager { startCronForLibrary(_library) { Logger.debug(`[CronManager] Init library scan cron for ${_library.name} on schedule ${_library.settings.autoScanCronExpression}`) const libScanCron = cron.schedule(_library.settings.autoScanCronExpression, async () => { - const oldLibrary = await Database.libraryModel.getOldById(_library.id) - if (!oldLibrary) { + const library = await Database.libraryModel.findByIdWithFolders(_library.id) + if (!library) { Logger.error(`[CronManager] Library not found for scan cron ${_library.id}`) } else { - Logger.debug(`[CronManager] Library scan cron executing for ${oldLibrary.name}`) - LibraryScanner.scan(oldLibrary) + Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`) + LibraryScanner.scan(library) } }) this.libraryScanCrons.push({ diff --git a/server/models/Library.js b/server/models/Library.js index 3ebd32df91..972aa264fd 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -1,6 +1,5 @@ const { DataTypes, Model } = require('sequelize') const Logger = require('../Logger') -const oldLibrary = require('../objects/Library') /** * @typedef LibrarySettingsObject @@ -98,114 +97,6 @@ class Library extends Model { }) } - /** - * Get all old libraries - * @returns {Promise} - */ - static async getAllOldLibraries() { - const libraries = await this.findAll({ - include: this.sequelize.models.libraryFolder, - order: [['displayOrder', 'ASC']] - }) - return libraries.map((lib) => this.getOldLibrary(lib)) - } - - /** - * Convert expanded Library to oldLibrary - * @param {Library} libraryExpanded - * @returns {oldLibrary} - */ - static getOldLibrary(libraryExpanded) { - const folders = libraryExpanded.libraryFolders.map((folder) => { - return { - id: folder.id, - fullPath: folder.path, - libraryId: folder.libraryId, - addedAt: folder.createdAt.valueOf() - } - }) - return new oldLibrary({ - id: libraryExpanded.id, - oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null, - name: libraryExpanded.name, - folders, - displayOrder: libraryExpanded.displayOrder, - icon: libraryExpanded.icon, - mediaType: libraryExpanded.mediaType, - provider: libraryExpanded.provider, - settings: libraryExpanded.settings, - lastScan: libraryExpanded.lastScan?.valueOf() || null, - lastScanVersion: libraryExpanded.lastScanVersion || null, - lastScanMetadataPrecedence: libraryExpanded.extraData?.lastScanMetadataPrecedence || null, - createdAt: libraryExpanded.createdAt.valueOf(), - lastUpdate: libraryExpanded.updatedAt.valueOf() - }) - } - - /** - * Update library and library folders - * @param {object} oldLibrary - * @returns {Promise} - */ - static async updateFromOld(oldLibrary) { - const existingLibrary = await this.findByPk(oldLibrary.id, { - include: this.sequelize.models.libraryFolder - }) - if (!existingLibrary) { - Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`) - return null - } - - const library = this.getFromOld(oldLibrary) - - const libraryFolders = oldLibrary.folders.map((folder) => { - return { - id: folder.id, - path: folder.fullPath, - libraryId: library.id - } - }) - for (const libraryFolder of libraryFolders) { - const existingLibraryFolder = existingLibrary.libraryFolders.find((lf) => lf.id === libraryFolder.id) - if (!existingLibraryFolder) { - await this.sequelize.models.libraryFolder.create(libraryFolder) - } else if (existingLibraryFolder.path !== libraryFolder.path) { - await existingLibraryFolder.update({ path: libraryFolder.path }) - } - } - - const libraryFoldersRemoved = existingLibrary.libraryFolders.filter((lf) => !libraryFolders.some((_lf) => _lf.id === lf.id)) - for (const existingLibraryFolder of libraryFoldersRemoved) { - await existingLibraryFolder.destroy() - } - - return existingLibrary.update(library) - } - - static getFromOld(oldLibrary) { - const extraData = {} - if (oldLibrary.oldLibraryId) { - extraData.oldLibraryId = oldLibrary.oldLibraryId - } - if (oldLibrary.lastScanMetadataPrecedence) { - extraData.lastScanMetadataPrecedence = oldLibrary.lastScanMetadataPrecedence - } - return { - id: oldLibrary.id, - name: oldLibrary.name, - displayOrder: oldLibrary.displayOrder, - icon: oldLibrary.icon || null, - mediaType: oldLibrary.mediaType || null, - provider: oldLibrary.provider, - settings: oldLibrary.settings?.toJSON() || {}, - lastScan: oldLibrary.lastScan || null, - lastScanVersion: oldLibrary.lastScanVersion || null, - createdAt: oldLibrary.createdAt, - updatedAt: oldLibrary.lastUpdate, - extraData - } - } - /** * Destroy library by id * @param {string} libraryId @@ -231,20 +122,6 @@ class Library extends Model { return libraries.map((l) => l.id) } - /** - * Find Library by primary key & return oldLibrary - * @param {string} libraryId - * @returns {Promise} Returns null if not found - */ - static async getOldById(libraryId) { - if (!libraryId) return null - const library = await this.findByPk(libraryId, { - include: this.sequelize.models.libraryFolder - }) - if (!library) return null - return this.getOldLibrary(library) - } - /** * Get the largest value in the displayOrder column * Used for setting a new libraries display order @@ -308,6 +185,12 @@ class Library extends Model { get isBook() { return this.mediaType === 'book' } + /** + * @returns {string[]} + */ + get lastScanMetadataPrecedence() { + return this.extraData?.lastScanMetadataPrecedence || [] + } /** * TODO: Update to use new model diff --git a/server/objects/Folder.js b/server/objects/Folder.js deleted file mode 100644 index 9ca6b21402..0000000000 --- a/server/objects/Folder.js +++ /dev/null @@ -1,38 +0,0 @@ -const uuidv4 = require("uuid").v4 - -class Folder { - constructor(folder = null) { - this.id = null - this.fullPath = null - this.libraryId = null - this.addedAt = null - - if (folder) { - this.construct(folder) - } - } - - construct(folder) { - this.id = folder.id - this.fullPath = folder.fullPath - this.libraryId = folder.libraryId - this.addedAt = folder.addedAt - } - - toJSON() { - return { - id: this.id, - fullPath: this.fullPath, - libraryId: this.libraryId, - addedAt: this.addedAt - } - } - - setData(data) { - this.id = data.id || uuidv4() - this.fullPath = data.fullPath - this.libraryId = data.libraryId - this.addedAt = Date.now() - } -} -module.exports = Folder \ No newline at end of file diff --git a/server/objects/Library.js b/server/objects/Library.js deleted file mode 100644 index a16e7b01d1..0000000000 --- a/server/objects/Library.js +++ /dev/null @@ -1,95 +0,0 @@ -const Folder = require('./Folder') -const LibrarySettings = require('./settings/LibrarySettings') -const { filePathToPOSIX } = require('../utils/fileUtils') - -class Library { - constructor(library = null) { - this.id = null - this.oldLibraryId = null // TODO: Temp - this.name = null - this.folders = [] - this.displayOrder = 1 - this.icon = 'database' - this.mediaType = 'book' // book, podcast - this.provider = 'google' - - this.lastScan = 0 - this.lastScanVersion = null - this.lastScanMetadataPrecedence = null - - this.settings = null - - this.createdAt = null - this.lastUpdate = null - - if (library) { - this.construct(library) - } - } - - get isPodcast() { - return this.mediaType === 'podcast' - } - get isBook() { - return this.mediaType === 'book' - } - - construct(library) { - this.id = library.id - this.oldLibraryId = library.oldLibraryId - this.name = library.name - this.folders = (library.folders || []).map((f) => new Folder(f)) - this.displayOrder = library.displayOrder || 1 - this.icon = library.icon || 'database' - this.mediaType = library.mediaType - this.provider = library.provider || 'google' - - this.settings = new LibrarySettings(library.settings) - if (library.settings === undefined) { - // LibrarySettings added in v2, migrate settings - this.settings.disableWatcher = !!library.disableWatcher - } - - this.lastScan = library.lastScan - this.lastScanVersion = library.lastScanVersion - this.lastScanMetadataPrecedence = library.lastScanMetadataPrecedence - - this.createdAt = library.createdAt - this.lastUpdate = library.lastUpdate - this.cleanOldValues() // mediaType changed for v2 and icon change for v2.2.2 - } - - cleanOldValues() { - const availableIcons = ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'] - if (!availableIcons.includes(this.icon)) { - if (this.icon === 'audiobook') this.icon = 'audiobookshelf' - else if (this.icon === 'book') this.icon = 'books-1' - else if (this.icon === 'comic') this.icon = 'file-picture' - else this.icon = 'database' - } - - const mediaTypes = ['podcast', 'book', 'video', 'music'] - if (!this.mediaType || !mediaTypes.includes(this.mediaType)) { - this.mediaType = 'book' - } - } - - toJSON() { - return { - id: this.id, - oldLibraryId: this.oldLibraryId, - name: this.name, - folders: (this.folders || []).map((f) => f.toJSON()), - displayOrder: this.displayOrder, - icon: this.icon, - mediaType: this.mediaType, - provider: this.provider, - settings: this.settings.toJSON(), - lastScan: this.lastScan, - lastScanVersion: this.lastScanVersion, - createdAt: this.createdAt, - lastUpdate: this.lastUpdate - } - } -} -module.exports = Library diff --git a/server/objects/settings/LibrarySettings.js b/server/objects/settings/LibrarySettings.js deleted file mode 100644 index 4369c0ff58..0000000000 --- a/server/objects/settings/LibrarySettings.js +++ /dev/null @@ -1,73 +0,0 @@ -const { BookCoverAspectRatio } = require('../../utils/constants') - -class LibrarySettings { - constructor(settings) { - this.coverAspectRatio = BookCoverAspectRatio.SQUARE - this.disableWatcher = false - this.skipMatchingMediaWithAsin = false - this.skipMatchingMediaWithIsbn = false - this.autoScanCronExpression = null - this.audiobooksOnly = false - this.epubsAllowScriptedContent = false - this.hideSingleBookSeries = false // Do not show series that only have 1 book - this.onlyShowLaterBooksInContinueSeries = false // Skip showing books that are earlier than the max sequence read - this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] - this.podcastSearchRegion = 'us' - - if (settings) { - this.construct(settings) - } - } - - construct(settings) { - this.coverAspectRatio = !isNaN(settings.coverAspectRatio) ? settings.coverAspectRatio : BookCoverAspectRatio.SQUARE - this.disableWatcher = !!settings.disableWatcher - this.skipMatchingMediaWithAsin = !!settings.skipMatchingMediaWithAsin - this.skipMatchingMediaWithIsbn = !!settings.skipMatchingMediaWithIsbn - this.autoScanCronExpression = settings.autoScanCronExpression || null - this.audiobooksOnly = !!settings.audiobooksOnly - this.epubsAllowScriptedContent = !!settings.epubsAllowScriptedContent - this.hideSingleBookSeries = !!settings.hideSingleBookSeries - this.onlyShowLaterBooksInContinueSeries = !!settings.onlyShowLaterBooksInContinueSeries - if (settings.metadataPrecedence) { - this.metadataPrecedence = [...settings.metadataPrecedence] - } else { - // Added in v2.4.5 - this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] - } - this.podcastSearchRegion = settings.podcastSearchRegion || 'us' - } - - toJSON() { - return { - coverAspectRatio: this.coverAspectRatio, - disableWatcher: this.disableWatcher, - skipMatchingMediaWithAsin: this.skipMatchingMediaWithAsin, - skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn, - autoScanCronExpression: this.autoScanCronExpression, - audiobooksOnly: this.audiobooksOnly, - epubsAllowScriptedContent: this.epubsAllowScriptedContent, - hideSingleBookSeries: this.hideSingleBookSeries, - onlyShowLaterBooksInContinueSeries: this.onlyShowLaterBooksInContinueSeries, - metadataPrecedence: [...this.metadataPrecedence], - podcastSearchRegion: this.podcastSearchRegion - } - } - - update(payload) { - let hasUpdates = false - for (const key in payload) { - if (key === 'metadataPrecedence') { - if (payload[key] && Array.isArray(payload[key]) && payload[key].join() !== this[key].join()) { - this[key] = payload[key] - hasUpdates = true - } - } else if (this[key] !== payload[key]) { - this[key] = payload[key] - hasUpdates = true - } - } - return hasUpdates - } -} -module.exports = LibrarySettings diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index ddf3c66b67..5ae5c06ada 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -1,10 +1,9 @@ const Path = require('path') -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 const fs = require('../libs/fsExtra') const date = require('../libs/dateAndTime') const Logger = require('../Logger') -const Library = require('../objects/Library') const { LogLevel } = require('../utils/constants') const { secondsToTimestamp, elapsedPretty } = require('../utils/index') @@ -12,7 +11,7 @@ class LibraryScan { constructor() { this.id = null this.type = null - /** @type {import('../objects/Library')} */ + /** @type {import('../models/Library')} */ this.library = null this.verbose = false @@ -33,13 +32,21 @@ class LibraryScan { this.logs = [] } - get libraryId() { return this.library.id } - get libraryName() { return this.library.name } - get libraryMediaType() { return this.library.mediaType } - get folders() { return this.library.folders } + get libraryId() { + return this.library.id + } + get libraryName() { + return this.library.name + } + get libraryMediaType() { + return this.library.mediaType + } + get libraryFolders() { + return this.library.libraryFolders + } get timestamp() { - return (new Date()).toISOString() + return new Date().toISOString() } get resultStats() { @@ -92,17 +99,22 @@ class LibraryScan { } } + /** + * + * @param {import('../models/Library')} library + * @param {string} type + */ setData(library, type = 'scan') { this.id = uuidv4() this.type = type - this.library = new Library(library.toJSON()) // clone library + this.library = library this.startedAt = Date.now() } /** - * - * @param {string} error + * + * @param {string} error */ setComplete(error = null) { this.finishedAt = Date.now() @@ -142,7 +154,7 @@ class LibraryScan { const outputPath = Path.join(scanLogDir, this.logFilename) const logLines = [JSON.stringify(this.toJSON())] - this.logs.forEach(l => { + this.logs.forEach((l) => { logLines.push(JSON.stringify(l)) }) await fs.writeFile(outputPath, logLines.join('\n') + '\n') @@ -150,4 +162,4 @@ class LibraryScan { Logger.info(`[LibraryScan] Scan log saved "${outputPath}"`) } } -module.exports = LibraryScan \ No newline at end of file +module.exports = LibraryScan diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index bbdde32833..75d18df099 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -45,7 +45,7 @@ class LibraryScanner { /** * - * @param {import('../objects/Library')} library + * @param {import('../models/Library')} library * @param {boolean} [forceRescan] */ async scan(library, forceRescan = false) { @@ -54,12 +54,12 @@ class LibraryScanner { return } - if (!library.folders.length) { + if (!library.libraryFolders.length) { Logger.warn(`[LibraryScanner] Library has no folders to scan "${library.name}"`) return } - if (library.isBook && library.settings.metadataPrecedence.join() !== library.lastScanMetadataPrecedence?.join()) { + if (library.isBook && library.settings.metadataPrecedence.join() !== library.lastScanMetadataPrecedence.join()) { const lastScanMetadataPrecedence = library.lastScanMetadataPrecedence?.join() || 'Unset' Logger.info(`[LibraryScanner] Library metadata precedence changed since last scan. From [${lastScanMetadataPrecedence}] to [${library.settings.metadataPrecedence.join()}]`) forceRescan = true @@ -103,9 +103,12 @@ class LibraryScanner { library.lastScan = Date.now() library.lastScanVersion = packageJson.version if (library.isBook) { - library.lastScanMetadataPrecedence = library.settings.metadataPrecedence + const newExtraData = library.extraData || {} + newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence + library.extraData = newExtraData + library.changed('extraData', true) } - await Database.libraryModel.updateFromOld(library) + await library.save() task.setFinished(libraryScan.scanResultsString) TaskManager.taskFinished(task) @@ -124,16 +127,16 @@ class LibraryScanner { async scanLibrary(libraryScan, forceRescan) { // Make sure library filter data is set // this is used to check for existing authors & series - await libraryFilters.getFilterData(libraryScan.library.mediaType, libraryScan.libraryId) + await libraryFilters.getFilterData(libraryScan.libraryMediaType, libraryScan.libraryId) /** @type {LibraryItemScanData[]} */ let libraryItemDataFound = [] // Scan each library folder - for (let i = 0; i < libraryScan.folders.length; i++) { - const folder = libraryScan.folders[i] + for (let i = 0; i < libraryScan.libraryFolders.length; i++) { + const folder = libraryScan.libraryFolders[i] const itemDataFoundInFolder = await this.scanFolder(libraryScan.library, folder) - libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`) + libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.path}"`) libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder) } @@ -283,12 +286,12 @@ class LibraryScanner { /** * Get scan data for library folder - * @param {import('../objects/Library')} library - * @param {import('../objects/Folder')} folder + * @param {import('../models/Library')} library + * @param {import('../models/LibraryFolder')} folder * @returns {LibraryItemScanData[]} */ async scanFolder(library, folder) { - const folderPath = fileUtils.filePathToPOSIX(folder.fullPath) + const folderPath = fileUtils.filePathToPOSIX(folder.path) const pathExists = await fs.pathExists(folderPath) if (!pathExists) { diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index e0bcf4fd69..4d67248cb4 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -15,7 +15,7 @@ const CoverManager = require('../managers/CoverManager') const TaskManager = require('../managers/TaskManager') class Scanner { - constructor() { } + constructor() {} async quickMatchLibraryItem(libraryItem, options = {}) { var provider = options.provider || 'google' @@ -23,9 +23,9 @@ class Scanner { var searchAuthor = options.author || libraryItem.media.metadata.authorName var overrideDefaults = options.overrideDefaults || false - // Set to override existing metadata if scannerPreferMatchedMetadata setting is true and + // Set to override existing metadata if scannerPreferMatchedMetadata setting is true and // the overrideDefaults option is not set or set to false. - if ((overrideDefaults == false) && (Database.serverSettings.scannerPreferMatchedMetadata)) { + if (overrideDefaults == false && Database.serverSettings.scannerPreferMatchedMetadata) { options.overrideCover = true options.overrideDetails = true } @@ -57,7 +57,8 @@ class Scanner { } updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) - } else if (libraryItem.isPodcast) { // Podcast quick match + } else if (libraryItem.isPodcast) { + // Podcast quick match var results = await PodcastFinder.search(searchTitle) if (!results.length) { return { @@ -88,7 +89,8 @@ class Scanner { } if (hasUpdated) { - if (libraryItem.isPodcast && libraryItem.media.metadata.feedUrl) { // Quick match all unmatched podcast episodes + if (libraryItem.isPodcast && libraryItem.media.metadata.feedUrl) { + // Quick match all unmatched podcast episodes await this.quickMatchPodcastEpisodes(libraryItem, options) } @@ -122,12 +124,16 @@ class Scanner { for (const key in matchDataTransformed) { if (matchDataTransformed[key]) { if (key === 'genres') { - if ((!libraryItem.media.metadata.genres.length || options.overrideDetails)) { + if (!libraryItem.media.metadata.genres.length || options.overrideDetails) { var genresArray = [] if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]] - else { // Genres should always be passed in as an array but just incase handle a string + else { + // Genres should always be passed in as an array but just incase handle a string Logger.warn(`[Scanner] quickMatch genres is not an array ${matchDataTransformed[key]}`) - genresArray = matchDataTransformed[key].split(',').map(v => v.trim()).filter(v => !!v) + genresArray = matchDataTransformed[key] + .split(',') + .map((v) => v.trim()) + .filter((v) => !!v) } updatePayload.metadata[key] = genresArray } @@ -153,27 +159,38 @@ class Scanner { for (const key in matchData) { if (matchData[key] && detailKeysToUpdate.includes(key)) { if (key === 'narrator') { - if ((!libraryItem.media.metadata.narratorName || options.overrideDetails)) { - updatePayload.metadata.narrators = matchData[key].split(',').map(v => v.trim()).filter(v => !!v) + if (!libraryItem.media.metadata.narratorName || options.overrideDetails) { + updatePayload.metadata.narrators = matchData[key] + .split(',') + .map((v) => v.trim()) + .filter((v) => !!v) } } else if (key === 'genres') { - if ((!libraryItem.media.metadata.genres.length || options.overrideDetails)) { + if (!libraryItem.media.metadata.genres.length || options.overrideDetails) { var genresArray = [] if (Array.isArray(matchData[key])) genresArray = [...matchData[key]] - else { // Genres should always be passed in as an array but just incase handle a string + else { + // Genres should always be passed in as an array but just incase handle a string Logger.warn(`[Scanner] quickMatch genres is not an array ${matchData[key]}`) - genresArray = matchData[key].split(',').map(v => v.trim()).filter(v => !!v) + genresArray = matchData[key] + .split(',') + .map((v) => v.trim()) + .filter((v) => !!v) } updatePayload.metadata[key] = genresArray } } else if (key === 'tags') { - if ((!libraryItem.media.tags.length || options.overrideDetails)) { + if (!libraryItem.media.tags.length || options.overrideDetails) { var tagsArray = [] if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]] - else tagsArray = matchData[key].split(',').map(v => v.trim()).filter(v => !!v) + else + tagsArray = matchData[key] + .split(',') + .map((v) => v.trim()) + .filter((v) => !!v) updatePayload[key] = tagsArray } - } else if ((!libraryItem.media.metadata[key] || options.overrideDetails)) { + } else if (!libraryItem.media.metadata[key] || options.overrideDetails) { updatePayload.metadata[key] = matchData[key] } } @@ -182,7 +199,10 @@ class Scanner { // Add or set author if not set if (matchData.author && (!libraryItem.media.metadata.authorName || options.overrideDetails)) { if (!Array.isArray(matchData.author)) { - matchData.author = matchData.author.split(',').map(au => au.trim()).filter(au => !!au) + matchData.author = matchData.author + .split(',') + .map((au) => au.trim()) + .filter((au) => !!au) } const authorPayload = [] for (const authorName of matchData.author) { @@ -227,7 +247,7 @@ class Scanner { } async quickMatchPodcastEpisodes(libraryItem, options = {}) { - const episodesToQuickMatch = libraryItem.media.episodes.filter(ep => !ep.enclosureUrl) // Only quick match episodes without enclosure + const episodesToQuickMatch = libraryItem.media.episodes.filter((ep) => !ep.enclosureUrl) // Only quick match episodes without enclosure if (!episodesToQuickMatch.length) return false const feed = await getPodcastFeed(libraryItem.media.metadata.feedUrl) @@ -283,10 +303,10 @@ class Scanner { /** * Quick match library items - * - * @param {import('../objects/Library')} library - * @param {import('../objects/LibraryItem')[]} libraryItems - * @param {LibraryScan} libraryScan + * + * @param {import('../models/Library')} library + * @param {import('../objects/LibraryItem')[]} libraryItems + * @param {LibraryScan} libraryScan * @returns {Promise} false if scan canceled */ async matchLibraryItemsChunk(library, libraryItems, libraryScan) { @@ -294,14 +314,12 @@ class Scanner { const libraryItem = libraryItems[i] if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) { - Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title - }" because it already has an ASIN (${i + 1} of ${libraryItems.length})`) + Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`) continue } if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) { - Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title - }" because it already has an ISBN (${i + 1} of ${libraryItems.length})`) + Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`) continue } @@ -324,8 +342,8 @@ class Scanner { /** * Quick match all library items for library - * - * @param {import('../objects/Library')} library + * + * @param {import('../models/Library')} library */ async matchLibraryItems(library) { if (library.mediaType === 'podcast') { @@ -360,7 +378,7 @@ class Scanner { offset += limit hasMoreChunks = libraryItems.length === limit - let oldLibraryItems = libraryItems.map(li => Database.libraryItemModel.getOldLibraryItem(li)) + let oldLibraryItems = libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li)) const shouldContinue = await this.matchLibraryItemsChunk(library, oldLibraryItems, libraryScan) if (!shouldContinue) { @@ -379,7 +397,7 @@ class Scanner { } delete LibraryScanner.cancelLibraryScan[libraryScan.libraryId] - LibraryScanner.librariesScanning = LibraryScanner.librariesScanning.filter(ls => ls.id !== library.id) + LibraryScanner.librariesScanning = LibraryScanner.librariesScanning.filter((ls) => ls.id !== library.id) TaskManager.taskFinished(task) } } diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js index cbb8edea01..8337f5aab1 100644 --- a/server/utils/migrations/dbMigration.js +++ b/server/utils/migrations/dbMigration.js @@ -1258,7 +1258,7 @@ async function handleOldLibraryItems(ctx) { */ async function handleOldLibraries(ctx) { const oldLibraries = await oldDbFiles.loadOldData('libraries') - const libraries = await ctx.models.library.getAllOldLibraries() + const libraries = await ctx.models.library.getAllWithFolders() let librariesUpdated = 0 for (const library of libraries) { @@ -1268,13 +1268,17 @@ async function handleOldLibraries(ctx) { return false } const folderPaths = ol.folders?.map((f) => f.fullPath) || [] - return folderPaths.join(',') === library.folders.map((f) => f.fullPath).join(',') + return folderPaths.join(',') === library.libraryFolders.map((f) => f.path).join(',') }) if (matchingOldLibrary) { - library.oldLibraryId = matchingOldLibrary.id + const newExtraData = library.extraData || {} + newExtraData.oldLibraryId = matchingOldLibrary.id + library.extraData = newExtraData + library.changed('extraData', true) + oldDbIdMap.libraries[library.oldLibraryId] = library.id - await ctx.models.library.updateFromOld(library) + await library.save() librariesUpdated++ } } From acc4bdbc5edcce86474c1f585dd5d2b96592f40c Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 29 Aug 2024 17:27:52 -0500 Subject: [PATCH 034/539] Add:Podcast latest page includes Mark as Finished button #3321 --- client/components/ui/ReadIconBtn.vue | 4 +- .../pages/library/_library/podcast/latest.vue | 49 +++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/client/components/ui/ReadIconBtn.vue b/client/components/ui/ReadIconBtn.vue index 934b0c6b3d..ddbd354187 100644 --- a/client/components/ui/ReadIconBtn.vue +++ b/client/components/ui/ReadIconBtn.vue @@ -24,12 +24,12 @@ export default { computed: {}, methods: { clickBtn(e) { + e.stopPropagation() if (this.disabled) { e.preventDefault() return } this.$emit('click') - e.stopPropagation() } }, mounted() {} @@ -54,4 +54,4 @@ button.icon-btn:hover:not(:disabled)::before { button.icon-btn:disabled::before { background-color: rgba(0, 0, 0, 0.2); } - \ No newline at end of file + diff --git a/client/pages/library/_library/podcast/latest.vue b/client/pages/library/_library/podcast/latest.vue index 800e7bfd72..d9271ae485 100644 --- a/client/pages/library/_library/podcast/latest.vue +++ b/client/pages/library/_library/podcast/latest.vue @@ -48,7 +48,7 @@

-
@@ -17,7 +17,12 @@ export default { props: { text: { type: String, - default: 'Please Wait...' + default: null + } + }, + computed: { + message() { + return this.text || this.$strings.MessagePleaseWait } } } @@ -67,4 +72,4 @@ export default { transform: translate(24px, 0); } } - \ No newline at end of file + diff --git a/client/pages/account.vue b/client/pages/account.vue index 86be607c6e..b6c932a0f5 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -117,10 +117,10 @@ export default { }, submitChangePassword() { if (this.newPassword !== this.confirmPassword) { - return this.$toast.error('New password and confirm password do not match') + return this.$toast.error(this.$strings.ToastUserPasswordMismatch) } if (this.password === this.newPassword) { - return this.$toast.error('Password and New Password cannot be the same') + return this.$toast.error(this.$strings.ToastUserPasswordMustChange) } this.changingPassword = true this.$axios @@ -130,16 +130,16 @@ export default { }) .then((res) => { if (res.success) { - this.$toast.success('Password Changed Successfully') + this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) this.resetForm() } else { - this.$toast.error(res.error || 'Unknown Error') + this.$toast.error(res.error || this.$strings.ToastUnknownError) } this.changingPassword = false }) .catch((error) => { console.error(error) - this.$toast.error('Api call failed') + this.$toast.error(this.$strings.ToastUnknownError) this.changingPassword = false }) } @@ -148,4 +148,4 @@ export default { this.selectedLanguage = this.$languageCodes.current } } - \ No newline at end of file + diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index c5da643aca..9dabb59d5c 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -560,7 +560,7 @@ export default { .catch((error) => { this.findingChapters = false console.error('Failed to get chapter data', error) - this.$toast.error('Failed to find chapters') + this.$toast.error(this.$strings.ToastFailedToLoadData) this.showFindChaptersModal = false }) }, @@ -611,7 +611,7 @@ export default { .$post(`/api/items/${this.libraryItem.id}/chapters`, payload) .then((data) => { if (data.updated) { - this.$toast.success('Chapters removed') + this.$toast.success(this.$strings.ToastChaptersRemoved) if (this.previousRoute) { this.$router.push(this.previousRoute) } else { @@ -623,7 +623,7 @@ export default { }) .catch((error) => { console.error('Failed to remove chapters', error) - this.$toast.error('Failed to remove chapters') + this.$toast.error(this.$strings.ToastRemoveFailed) }) .finally(() => { this.saving = false diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 6be07349be..7de82b510a 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -331,11 +331,11 @@ export default { this.$axios .$delete(`/api/tools/item/${this.libraryItemId}/encode-m4b`) .then(() => { - this.$toast.success('Encode canceled') + this.$toast.success(this.$strings.ToastEncodeCancelSucces) }) .catch((error) => { console.error('Failed to cancel encode', error) - this.$toast.error('Failed to cancel encode') + this.$toast.error(this.$strings.ToastEncodeCancelFailed) }) .finally(() => { this.isCancelingEncode = false diff --git a/client/pages/batch/index.vue b/client/pages/batch/index.vue index c73edd405b..1f11938784 100644 --- a/client/pages/batch/index.vue +++ b/client/pages/batch/index.vue @@ -366,7 +366,7 @@ export default { } } if (!updates.length) { - return this.$toast.warning('No updates were made') + return this.$toast.warning(this.$strings.ToastNoUpdatesNecessary) } console.log('Pushing updates', updates) @@ -406,4 +406,4 @@ export default { transform: translateY(-100%); transition: all 150ms ease-in 0s; } - \ No newline at end of file + diff --git a/client/pages/config/backups.vue b/client/pages/config/backups.vue index d74dc4d621..0c64ddf6f5 100644 --- a/client/pages/config/backups.vue +++ b/client/pages/config/backups.vue @@ -162,7 +162,7 @@ export default { }) .catch((error) => { console.error('Failed to save backup path', error) - const errorMsg = error.response?.data || 'Failed to save backup path' + const errorMsg = error.response?.data || this.$strings.ToastBackupPathUpdateFailed this.$toast.error(errorMsg) }) .finally(() => { @@ -171,11 +171,11 @@ export default { }, updateBackupsSettings() { if (isNaN(this.maxBackupSize) || this.maxBackupSize < 0) { - this.$toast.error('Invalid maximum backup size') + this.$toast.error(this.$strings.ToastBackupInvalidMaxSize) return } if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) { - this.$toast.error('Invalid number of backups to keep') + this.$toast.error(this.$strings.ToastBackupInvalidMaxKeep) return } const updatePayload = { diff --git a/client/pages/config/email.vue b/client/pages/config/email.vue index 3637e3124f..212c51f31f 100644 --- a/client/pages/config/email.vue +++ b/client/pages/config/email.vue @@ -109,7 +109,7 @@
-

No Devices

+

{{ $strings.MessageNoDevices }}

@@ -199,7 +199,7 @@ export default { }, deleteDeviceClick(device) { const payload = { - message: `Are you sure you want to delete e-reader device "${device.name}"?`, + message: this.$getString('MessageConfirmDeleteDevice', [device.name]), callback: (confirmed) => { if (confirmed) { this.deleteDevice(device) @@ -218,11 +218,10 @@ export default { .$post(`/api/emails/ereader-devices`, payload) .then((data) => { this.ereaderDevicesUpdated(data.ereaderDevices) - this.$toast.success('Device deleted') }) .catch((error) => { console.error('Failed to delete device', error) - this.$toast.error('Failed to delete device') + this.$toast.error(this.$strings.ToastRemoveFailed) }) .finally(() => { this.deletingDeviceName = null @@ -246,11 +245,11 @@ export default { this.$axios .$post('/api/emails/test') .then(() => { - this.$toast.success('Test Email Sent') + this.$toast.success(this.$strings.ToastDeviceTestEmailSuccess) }) .catch((error) => { console.error('Failed to send test email', error) - const errorMsg = error.response.data || 'Failed to send test email' + const errorMsg = error.response.data || this.$strings.ToastDeviceTestEmailFailed this.$toast.error(errorMsg) }) .finally(() => { @@ -289,11 +288,11 @@ export default { this.newSettings = { ...data.settings } - this.$toast.success('Email settings updated') + this.$toast.success(this.$strings.ToastEmailSettingsUpdateSuccess) }) .catch((error) => { console.error('Failed to update email settings', error) - this.$toast.error('Failed to update email settings') + this.$toast.error(this.$strings.ToastEmailSettingsUpdateFailed) }) .finally(() => { this.savingSettings = false diff --git a/client/pages/config/item-metadata-utils/genres.vue b/client/pages/config/item-metadata-utils/genres.vue index 5a61d51a67..e041244cb2 100644 --- a/client/pages/config/item-metadata-utils/genres.vue +++ b/client/pages/config/item-metadata-utils/genres.vue @@ -130,7 +130,7 @@ export default { }) .catch((error) => { console.error('Failed to rename genre', error) - this.$toast.error('Failed to rename genre') + this.$toast.error(this.$strings.ToastRenameFailed) }) .finally(() => { this.loading = false @@ -147,7 +147,7 @@ export default { }) .catch((error) => { console.error('Failed to remove genre', error) - this.$toast.error('Failed to remove genre') + this.$toast.error(this.$strings.ToastRemoveFailed) }) .finally(() => { this.loading = false diff --git a/client/pages/config/item-metadata-utils/tags.vue b/client/pages/config/item-metadata-utils/tags.vue index a98f39b4e9..0e14f97c8b 100644 --- a/client/pages/config/item-metadata-utils/tags.vue +++ b/client/pages/config/item-metadata-utils/tags.vue @@ -126,7 +126,7 @@ export default { }) .catch((error) => { console.error('Failed to rename tag', error) - this.$toast.error('Failed to rename tag') + this.$toast.error(this.$strings.ToastRenameFailed) }) .finally(() => { this.loading = false @@ -143,7 +143,7 @@ export default { }) .catch((error) => { console.error('Failed to remove tag', error) - this.$toast.error('Failed to remove tag') + this.$toast.error(this.$strings.ToastRemoveFailed) }) .finally(() => { this.loading = false diff --git a/client/pages/config/notifications.vue b/client/pages/config/notifications.vue index ad346a5d7f..24ea6a6cca 100644 --- a/client/pages/config/notifications.vue +++ b/client/pages/config/notifications.vue @@ -105,12 +105,12 @@ export default { } if (isNaN(this.maxNotificationQueue) || this.maxNotificationQueue <= 0) { - this.$toast.error('Max notification queue must be >= 0') + this.$toast.error(this.$strings.ToastNotificationQueueMaximum) return false } if (isNaN(this.maxFailedAttempts) || this.maxFailedAttempts <= 0) { - this.$toast.error('Max failed attempts must be >= 0') + this.$toast.error(this.$strings.ToastNotificationFailedMaximum) return false } @@ -128,11 +128,11 @@ export default { this.$axios .$patch('/api/notifications', updatePayload) .then(() => { - this.$toast.success('Notification settings updated') + this.$toast.success(this.$strings.ToastNotificationSettingsUpdateSuccess) }) .catch((error) => { console.error('Failed to update notification settings', error) - this.$toast.error('Failed to update notification settings') + this.$toast.error(this.$strings.ToastNotificationSettingsUpdateFailed) }) .finally(() => { this.savingSettings = false diff --git a/client/pages/config/sessions.vue b/client/pages/config/sessions.vue index edb14cd23d..59ff75587c 100644 --- a/client/pages/config/sessions.vue +++ b/client/pages/config/sessions.vue @@ -290,7 +290,6 @@ export default { this.$axios .$post(`/api/sessions/batch/delete`, payload) .then(() => { - this.$toast.success('Sessions removed') if (isAllSessions) { // If all sessions were removed from the current page then go to the previous page if (this.currentPage > 0) { @@ -303,7 +302,7 @@ export default { } }) .catch((error) => { - const errorMsg = error.response?.data || 'Failed to remove sessions' + const errorMsg = error.response?.data || this.$strings.ToastRemoveFailed this.$toast.error(errorMsg) }) .finally(() => { @@ -358,12 +357,13 @@ export default { }) if (!libraryItem) { - this.$toast.error('Failed to get library item') + this.$toast.error(this.$strings.ToastFailedToLoadData) this.processingGoToTimestamp = false return } if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) { - this.$toast.error('Failed to get podcast episode') + console.error('Episode not found in library item', session.episodeId, libraryItem.media.episodes) + this.$toast.error(this.$strings.ToastFailedToLoadData) this.processingGoToTimestamp = false return } @@ -377,7 +377,7 @@ export default { episodeId: episode.id, title: episode.title, subtitle: libraryItem.media.metadata.title, - caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date', + caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate, duration: episode.audioFile.duration || null, coverPath: libraryItem.media.coverPath || null } diff --git a/client/pages/config/users/_id/sessions.vue b/client/pages/config/users/_id/sessions.vue index 8e7ebfb860..6b47567769 100644 --- a/client/pages/config/users/_id/sessions.vue +++ b/client/pages/config/users/_id/sessions.vue @@ -127,12 +127,13 @@ export default { }) if (!libraryItem) { - this.$toast.error('Failed to get library item') + this.$toast.error(this.$strings.ToastFailedToLoadData) this.processingGoToTimestamp = false return } if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) { - this.$toast.error('Failed to get podcast episode') + console.error('Episode not found in library item', session.episodeId, libraryItem.media.episodes) + this.$toast.error(this.$strings.ToastFailedToLoadData) this.processingGoToTimestamp = false return } @@ -146,7 +147,7 @@ export default { episodeId: episode.id, title: episode.title, subtitle: libraryItem.media.metadata.title, - caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date', + caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate, duration: episode.audioFile.duration || null, coverPath: libraryItem.media.coverPath || null } diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 6b58135bbf..efc3a4624d 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -484,23 +484,23 @@ export default { this.$axios .$get(`/api/podcasts/${this.libraryItemId}/clear-queue`) .then(() => { - this.$toast.success('Episode download queue cleared') + this.$toast.success(this.$strings.ToastEpisodeDownloadQueueClearSuccess) this.episodeDownloadQueued = [] }) .catch((error) => { console.error('Failed to clear queue', error) - this.$toast.error('Failed to clear queue') + this.$toast.error(this.$strings.ToastEpisodeDownloadQueueClearFailed) }) } }, async findEpisodesClick() { if (!this.mediaMetadata.feedUrl) { - return this.$toast.error('Podcast does not have an RSS Feed') + return this.$toast.error(this.$strings.ToastNoRSSFeed) } this.fetchingRSSFeed = true var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => { console.error('Failed to get feed', error) - this.$toast.error('Failed to get podcast feed') + this.$toast.error(this.$strings.ToastPodcastGetFeedFailed) return null }) this.fetchingRSSFeed = false @@ -509,7 +509,7 @@ export default { console.log('Podcast feed', payload) const podcastfeed = payload.podcast if (!podcastfeed.episodes || !podcastfeed.episodes.length) { - this.$toast.info('No episodes found in RSS feed') + this.$toast.info(this.$strings.ToastPodcastNoEpisodesInFeed) return } @@ -578,7 +578,7 @@ export default { episodeId: episode.id, title: episode.title, subtitle: this.title, - caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date', + caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate, duration: episode.audioFile.duration || null, coverPath: this.libraryItem.media.coverPath || null }) @@ -622,13 +622,12 @@ export default { }, clearProgressClick() { if (!this.userMediaProgress) return - if (confirm(`Are you sure you want to reset your progress?`)) { + if (confirm(this.$strings.MessageConfirmResetProgress)) { this.resettingProgress = true this.$axios .$delete(`/api/me/progress/${this.userMediaProgress.id}`) .then(() => { console.log('Progress reset complete') - this.$toast.success(`Your progress was reset`) this.resettingProgress = false }) .catch((error) => { @@ -722,12 +721,12 @@ export default { this.$axios .$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`) .then(() => { - this.$toast.success('Item deleted') + this.$toast.success(this.$strings.ToastItemDeletedSuccess) this.$router.replace(`/library/${this.libraryId}`) }) .catch((error) => { console.error('Failed to delete item', error) - this.$toast.error('Failed to delete item') + this.$toast.error(this.$strings.ToastItemDeleteFailed) }) } }, diff --git a/client/pages/library/_library/narrators.vue b/client/pages/library/_library/narrators.vue index 22d583c704..e2a45da44e 100644 --- a/client/pages/library/_library/narrators.vue +++ b/client/pages/library/_library/narrators.vue @@ -138,7 +138,7 @@ export default { }) .catch((error) => { console.error('Failed to remove narrator', error) - this.$toast.error('Failed to remove narrator') + this.$toast.error(this.$strings.ToastRemoveFailed) this.loading = false }) }, @@ -158,4 +158,4 @@ export default { }, beforeDestroy() {} } - \ No newline at end of file + diff --git a/client/pages/library/_library/podcast/download-queue.vue b/client/pages/library/_library/podcast/download-queue.vue index 49b4d4da69..777ddfc16d 100644 --- a/client/pages/library/_library/podcast/download-queue.vue +++ b/client/pages/library/_library/podcast/download-queue.vue @@ -111,7 +111,7 @@ export default { this.processing = true const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => { console.error('Failed to get download queue', error) - this.$toast.error('Failed to get download queue') + this.$toast.error(this.$strings.ToastFailedToLoadData) return null }) this.processing = false diff --git a/client/pages/library/_library/podcast/latest.vue b/client/pages/library/_library/podcast/latest.vue index d9271ae485..c663b8f593 100644 --- a/client/pages/library/_library/podcast/latest.vue +++ b/client/pages/library/_library/podcast/latest.vue @@ -234,7 +234,7 @@ export default { episodeId: episode.id, title: episode.title, subtitle: episode.podcast.metadata.title, - caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date', + caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate, duration: episode.duration || null, coverPath: episode.podcast.coverPath || null }) @@ -251,7 +251,7 @@ export default { this.processing = true const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => { console.error('Failed to get recent episodes', error) - this.$toast.error('Failed to get recent episodes') + this.$toast.error(this.$strings.ToastFailedToLoadData) return null }) this.processing = false @@ -271,7 +271,7 @@ export default { episodeId: episode.id, title: episode.title, subtitle: episode.podcast.metadata.title, - caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date', + caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate, duration: episode.duration || null, coverPath: episode.podcast.coverPath || null } diff --git a/client/pages/library/_library/podcast/search.vue b/client/pages/library/_library/podcast/search.vue index c7808979f0..b80ca2f8f3 100644 --- a/client/pages/library/_library/podcast/search.vue +++ b/client/pages/library/_library/podcast/search.vue @@ -146,7 +146,7 @@ export default { this.processing = true var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed }).catch((error) => { console.error('Failed to get feed', error) - this.$toast.error('Failed to get podcast feed') + this.$toast.error(this.$strings.ToastPodcastGetFeedFailed) return null }) this.processing = false @@ -197,7 +197,7 @@ export default { this.processing = true const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => { console.error('Failed to get feed', error) - this.$toast.error('Failed to get podcast feed') + this.$toast.error(this.$strings.ToastPodcastGetFeedFailed) return null }) this.processing = false diff --git a/client/pages/login.vue b/client/pages/login.vue index d12600c99d..a853def452 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -132,11 +132,11 @@ export default { methods: { async submitServerSetup() { if (!this.newRoot.username || !this.newRoot.username.trim()) { - this.$toast.error('Must enter a root username') + this.$toast.error(this.$strings.ToastUserRootRequireName) return } if (this.newRoot.password !== this.confirmPassword) { - this.$toast.error('Password mismatch') + this.$toast.error(this.$strings.ToastUserPasswordMismatch) return } if (!this.newRoot.password) { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 7420c1e75a..3dc294090c 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Choose files", "ButtonClearFilter": "Clear Filter", "ButtonCloseFeed": "Close Feed", + "ButtonCloseSession": "Close Open Session", "ButtonCollections": "Collections", "ButtonConfigureScanner": "Configure Scanner", "ButtonCreate": "Create", @@ -28,6 +29,9 @@ "ButtonEdit": "Edit", "ButtonEditChapters": "Edit Chapters", "ButtonEditPodcast": "Edit Podcast", + "ButtonEnable": "Enable", + "ButtonFireAndFail": "Fire and Fail", + "ButtonFireOnTest": "Fire onTest event", "ButtonForceReScan": "Force Re-Scan", "ButtonFullPath": "Full Path", "ButtonHide": "Hide", @@ -56,6 +60,7 @@ "ButtonPlaylists": "Playlists", "ButtonPrevious": "Previous", "ButtonPreviousChapter": "Previous Chapter", + "ButtonProbeAudioFile": "Probe Audio File", "ButtonPurgeAllCache": "Purge All Cache", "ButtonPurgeItemsCache": "Purge Items Cache", "ButtonQueueAddItem": "Add to queue", @@ -93,6 +98,7 @@ "ButtonStats": "Stats", "ButtonSubmit": "Submit", "ButtonTest": "Test", + "ButtonUnlinkOpedId": "Unlink OpenID", "ButtonUpload": "Upload", "ButtonUploadBackup": "Upload Backup", "ButtonUploadCover": "Upload Cover", @@ -105,6 +111,7 @@ "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", "ErrorUploadLacksTitle": "Must have a title", "HeaderAccount": "Account", + "HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider", "HeaderAdvanced": "Advanced", "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudioTracks": "Audio Tracks", @@ -150,6 +157,8 @@ "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", + "HeaderNotificationCreate": "Create Notification", + "HeaderNotificationUpdate": "Update Notification", "HeaderNotifications": "Notifications", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Open RSS Feed", @@ -206,8 +215,8 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", - "LabelAdded": "Added", "LabelAddedAt": "Added At", + "LabelAddedDate": "Added {0}", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "All Users", @@ -298,6 +307,7 @@ "LabelEpisode": "Episode", "LabelEpisodeTitle": "Episode Title", "LabelEpisodeType": "Episode Type", + "LabelEpisodes": "Episodes", "LabelExample": "Example", "LabelExpandSeries": "Expand Series", "LabelExpandSubSeries": "Expand Sub Series", @@ -309,7 +319,9 @@ "LabelFetchingMetadata": "Fetching Metadata", "LabelFile": "File", "LabelFileBirthtime": "File Birthtime", + "LabelFileBornDate": "Born {0}", "LabelFileModified": "File Modified", + "LabelFileModifiedDate": "Modified {0}", "LabelFilename": "Filename", "LabelFilterByUser": "Filter by User", "LabelFindEpisodes": "Find Episodes", @@ -448,8 +460,10 @@ "LabelPrimaryEbook": "Primary ebook", "LabelProgress": "Progress", "LabelProvider": "Provider", + "LabelProviderAuthorizationValue": "Authorization Header Value", "LabelPubDate": "Pub Date", "LabelPublishYear": "Publish Year", + "LabelPublishedDate": "Published {0}", "LabelPublisher": "Publisher", "LabelPublishers": "Publishers", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email", @@ -595,6 +609,7 @@ "LabelUnabridged": "Unabridged", "LabelUndo": "Undo", "LabelUnknown": "Unknown", + "LabelUnknownPublishDate": "Unknown publish date", "LabelUpdateCover": "Update Cover", "LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located", "LabelUpdateDetails": "Update Details", @@ -643,16 +658,22 @@ "MessageCheckingCron": "Checking cron...", "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", + "MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{0}\"?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", + "MessageConfirmDeleteMetadataProvider": "Are you sure you want to delete custom metadata provider \"{0}\"?", + "MessageConfirmDeleteNotification": "Are you sure you want to delete this notification?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", + "MessageConfirmMarkItemFinished": "Are you sure you want to mark \"{0}\" as finished?", + "MessageConfirmMarkItemNotFinished": "Are you sure you want to mark \"{0}\" as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmNotificationTestTrigger": "Trigger this notification with test data?", "MessageConfirmPurgeCache": "Purge cache will delete the entire directory at /metadata/cache.

Are you sure you want to remove the cache directory?", "MessageConfirmPurgeItemsCache": "Purge items cache will delete the entire directory at /metadata/cache/items.
Are you sure?", "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files.

Would you like to continue?", @@ -671,7 +692,9 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmResetProgress": "Are you sure you want to reset your progress?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Are you sure you want to unlink this user from OpenID?", "MessageDownloadingEpisode": "Downloading episode", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageEmbedFailed": "Embed Failed!", @@ -706,6 +729,7 @@ "MessageNoCollections": "No Collections", "MessageNoCoversFound": "No Covers Found", "MessageNoDescription": "No description", + "MessageNoDevices": "No devices", "MessageNoDownloadsInProgress": "No downloads currently in progress", "MessageNoDownloadsQueued": "No downloads queued", "MessageNoEpisodeMatchesFound": "No episode matches found", @@ -725,7 +749,6 @@ "MessageNoSeries": "No Series", "MessageNoTags": "No Tags", "MessageNoTasksRunning": "No Tasks Running", - "MessageNoUpdateNecessary": "No update necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUserPlaylists": "You have no playlists", "MessageNotYetImplemented": "Not yet implemented", @@ -734,6 +757,7 @@ "MessagePauseChapter": "Pause chapter playback", "MessagePlayChapter": "Listen to beginning of chapter", "MessagePlaylistCreateFromCollection": "Create playlist from collection", + "MessagePleaseWait": "Please wait...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching", "MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.", "MessageRemoveChapter": "Remove chapter", @@ -794,24 +818,32 @@ "StatsYearInReview": "YEAR IN REVIEW", "ToastAccountUpdateFailed": "Failed to update account", "ToastAccountUpdateSuccess": "Account updated", - "ToastAuthorImageRemoveFailed": "Failed to remove image", + "ToastAppriseUrlRequired": "Must enter an Apprise URL", "ToastAuthorImageRemoveSuccess": "Author image removed", + "ToastAuthorNotFound": "Author \"{0}\" not found", + "ToastAuthorRemoveSuccess": "Author removed", + "ToastAuthorSearchNotFound": "Author not found", "ToastAuthorUpdateFailed": "Failed to update author", "ToastAuthorUpdateMerged": "Author merged", "ToastAuthorUpdateSuccess": "Author updated", "ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)", + "ToastBackupAppliedSuccess": "Backup applied", "ToastBackupCreateFailed": "Failed to create backup", "ToastBackupCreateSuccess": "Backup created", "ToastBackupDeleteFailed": "Failed to delete backup", "ToastBackupDeleteSuccess": "Backup deleted", + "ToastBackupInvalidMaxKeep": "Invalid number of backups to keep", + "ToastBackupInvalidMaxSize": "Invalid maximum backup size", + "ToastBackupPathUpdateFailed": "Failed to update backup path", "ToastBackupRestoreFailed": "Failed to restore backup", "ToastBackupUploadFailed": "Failed to upload backup", "ToastBackupUploadSuccess": "Backup uploaded", + "ToastBatchDeleteFailed": "Batch delete failed", + "ToastBatchDeleteSuccess": "Batch delete success", "ToastBatchUpdateFailed": "Batch update failed", "ToastBatchUpdateSuccess": "Batch update success", "ToastBookmarkCreateFailed": "Failed to create bookmark", "ToastBookmarkCreateSuccess": "Bookmark added", - "ToastBookmarkRemoveFailed": "Failed to remove bookmark", "ToastBookmarkRemoveSuccess": "Bookmark removed", "ToastBookmarkUpdateFailed": "Failed to update bookmark", "ToastBookmarkUpdateSuccess": "Bookmark updated", @@ -819,25 +851,46 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersMustHaveTitles": "Chapters must have titles", - "ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection", + "ToastChaptersRemoved": "Chapters removed", + "ToastCollectionItemsAddFailed": "Item(s) added to collection failed", + "ToastCollectionItemsAddSuccess": "Item(s) added to collection success", "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection", - "ToastCollectionRemoveFailed": "Failed to remove collection", "ToastCollectionRemoveSuccess": "Collection removed", "ToastCollectionUpdateFailed": "Failed to update collection", "ToastCollectionUpdateSuccess": "Collection updated", + "ToastCoverUpdateFailed": "Cover update failed", "ToastDeleteFileFailed": "Failed to delete file", "ToastDeleteFileSuccess": "File deleted", + "ToastDeviceAddFailed": "Failed to add device", + "ToastDeviceNameAlreadyExists": "Ereader device with that name already exists", + "ToastDeviceTestEmailFailed": "Failed to send test email", + "ToastDeviceTestEmailSuccess": "Test email sent", + "ToastDeviceUpdateFailed": "Failed to update device", + "ToastEmailSettingsUpdateFailed": "Failed to update email settings", + "ToastEmailSettingsUpdateSuccess": "Email settings updated", + "ToastEncodeCancelFailed": "Failed to cancel encode", + "ToastEncodeCancelSucces": "Encode canceled", + "ToastEpisodeDownloadQueueClearFailed": "Failed to clear queue", + "ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared", "ToastErrorCannotShare": "Cannot share natively on this device", "ToastFailedToLoadData": "Failed to load data", + "ToastFailedToShare": "Failed to share", + "ToastFailedToUpdateAccount": "Failed to update account", + "ToastFailedToUpdateUser": "Failed to update user", + "ToastInvalidImageUrl": "Invalid image URL", + "ToastInvalidUrl": "Invalid URL", "ToastItemCoverUpdateFailed": "Failed to update item cover", "ToastItemCoverUpdateSuccess": "Item cover updated", + "ToastItemDeletedFailed": "Failed to delete item", + "ToastItemDeletedSuccess": "Deleted item", "ToastItemDetailsUpdateFailed": "Failed to update item details", "ToastItemDetailsUpdateSuccess": "Item details updated", - "ToastItemDetailsUpdateUnneeded": "No updates needed for item details", "ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished", "ToastItemMarkedAsFinishedSuccess": "Item marked as Finished", "ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished", "ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished", + "ToastItemUpdateFailed": "Failed to update item", + "ToastItemUpdateSuccess": "Item updated", "ToastLibraryCreateFailed": "Failed to create library", "ToastLibraryCreateSuccess": "Library \"{0}\" created", "ToastLibraryDeleteFailed": "Failed to delete library", @@ -846,32 +899,78 @@ "ToastLibraryScanStarted": "Library scan started", "ToastLibraryUpdateFailed": "Failed to update library", "ToastLibraryUpdateSuccess": "Library \"{0}\" updated", + "ToastNameEmailRequired": "Name and email are required", + "ToastNameRequired": "Name is required", + "ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"", + "ToastNewUserCreatedSuccess": "New account created", + "ToastNewUserLibraryError": "Must select at least one library", + "ToastNewUserPasswordError": "Must have a password, only root user can have an empty password", + "ToastNewUserTagError": "Must select at least one tag", + "ToastNewUserUsernameError": "Enter a username", + "ToastNoUpdatesNecessary": "No updates necessary", + "ToastNotificationCreateFailed": "Failed to create notification", + "ToastNotificationDeleteFailed": "Failed to delete notification", + "ToastNotificationFailedMaximum": "Max failed attempts must be >= 0", + "ToastNotificationQueueMaximum": "Max notification queue must be >= 0", + "ToastNotificationSettingsUpdateFailed": "Failed to update notification settings", + "ToastNotificationSettingsUpdateSuccess": "Notification settings updated", + "ToastNotificationTestTriggerFailed": "Failed to trigger test notification", + "ToastNotificationTestTriggerSuccess": "Triggered test notification", + "ToastNotificationUpdateFailed": "Failed to update notification", + "ToastNotificationUpdateSuccess": "Notification updated", "ToastPlaylistCreateFailed": "Failed to create playlist", "ToastPlaylistCreateSuccess": "Playlist created", - "ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateSuccess": "Playlist updated", "ToastPodcastCreateFailed": "Failed to create podcast", "ToastPodcastCreateSuccess": "Podcast created successfully", + "ToastPodcastGetFeedFailed": "Failed to get podcast feed", + "ToastPodcastNoEpisodesInFeed": "No episodes found in RSS feed", + "ToastPodcastNoRssFeed": "Podcast does not have an RSS feed", + "ToastProviderCreatedFailed": "Failed to add provider", + "ToastProviderCreatedSuccess": "New provider added", + "ToastProviderNameAndUrlRequired": "Name and Url required", + "ToastProviderRemoveSuccess": "Provider removed", "ToastRSSFeedCloseFailed": "Failed to close RSS feed", "ToastRSSFeedCloseSuccess": "RSS feed closed", + "ToastRemoveFailed": "Failed to remove", "ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection", "ToastRemoveItemFromCollectionSuccess": "Item removed from collection", + "ToastRemoveItemsWithIssuesFailed": "Failed to remove library items with issues", + "ToastRemoveItemsWithIssuesSuccess": "Removed library items with issues", + "ToastRenameFailed": "Failed to rename", + "ToastRescanFailed": "Re-Scan Failed for {0}", + "ToastRescanRemoved": "Re-Scan complete item was removed", + "ToastRescanUpToDate": "Re-Scan complete item was up to date", + "ToastRescanUpdated": "Re-Scan complete item was updated", + "ToastScanFailed": "Failed to scan library item", + "ToastSelectAtLeastOneUser": "Select at least one user", "ToastSendEbookToDeviceFailed": "Failed to send ebook to device", "ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"", "ToastSeriesUpdateFailed": "Series update failed", "ToastSeriesUpdateSuccess": "Series update success", "ToastServerSettingsUpdateFailed": "Failed to update server settings", "ToastServerSettingsUpdateSuccess": "Server settings updated", + "ToastSessionCloseFailed": "Failed to close session", "ToastSessionDeleteFailed": "Failed to delete session", "ToastSessionDeleteSuccess": "Session deleted", + "ToastSlugMustChange": "Slug contains invalid characters", + "ToastSlugRequired": "Slug is required", "ToastSocketConnected": "Socket connected", "ToastSocketDisconnected": "Socket disconnected", "ToastSocketFailedToConnect": "Socket failed to connect", "ToastSortingPrefixesEmptyError": "Must have at least 1 sorting prefix", "ToastSortingPrefixesUpdateFailed": "Failed to update sorting prefixes", "ToastSortingPrefixesUpdateSuccess": "Sorting prefixes updated ({0} items)", + "ToastTitleRequired": "Title is required", + "ToastUnknownError": "Unknown error", + "ToastUnlinkOpenIdFailed": "Failed to unlink user from OpenID", + "ToastUnlinkOpenIdSuccess": "User unlinked from OpenID", "ToastUserDeleteFailed": "Failed to delete user", - "ToastUserDeleteSuccess": "User deleted" + "ToastUserDeleteSuccess": "User deleted", + "ToastUserPasswordChangeSuccess": "Password changed successfully", + "ToastUserPasswordMismatch": "Passwords do not match", + "ToastUserPasswordMustChange": "New password cannot match old password", + "ToastUserRootRequireName": "Must enter a root username" } From ba742563c24b35ebe73b05ae12ee965526cb28fb Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 31 Aug 2024 13:27:48 -0500 Subject: [PATCH 036/539] Remove old Author object & fix issue deleting empty authors --- server/Database.js | 22 +-- server/controllers/AuthorController.js | 163 +++++++++++--------- server/controllers/LibraryController.js | 3 +- server/controllers/LibraryItemController.js | 19 ++- server/managers/CacheManager.js | 7 + server/models/Author.js | 121 +++++---------- server/models/LibraryItem.js | 2 +- server/objects/entities/Author.js | 101 ------------ server/routers/ApiRouter.js | 63 +++++++- server/scanner/Scanner.js | 12 +- server/utils/parsers/parseNameString.js | 21 +-- server/utils/queries/authorFilters.js | 3 +- server/utils/queries/libraryFilters.js | 4 +- 13 files changed, 227 insertions(+), 314 deletions(-) delete mode 100644 server/objects/entities/Author.js diff --git a/server/Database.js b/server/Database.js index a3959ccd4f..6628eb0547 100644 --- a/server/Database.js +++ b/server/Database.js @@ -462,26 +462,6 @@ class Database { await this.models.series.removeById(seriesId) } - async createAuthor(oldAuthor) { - if (!this.sequelize) return false - await this.models.author.createFromOld(oldAuthor) - } - - async createBulkAuthors(oldAuthors) { - if (!this.sequelize) return false - await this.models.author.createBulkFromOld(oldAuthors) - } - - updateAuthor(oldAuthor) { - if (!this.sequelize) return false - return this.models.author.updateFromOld(oldAuthor) - } - - async removeAuthor(authorId) { - if (!this.sequelize) return false - await this.models.author.removeById(authorId) - } - async createBulkBookAuthors(bookAuthors) { if (!this.sequelize) return false await this.models.bookAuthor.bulkCreate(bookAuthors) @@ -684,7 +664,7 @@ class Database { */ async getAuthorIdByName(libraryId, authorName) { if (!this.libraryFilterData[libraryId]) { - return (await this.authorModel.getOldByNameAndLibrary(authorName, libraryId))?.id || null + return (await this.authorModel.getByNameAndLibrary(authorName, libraryId))?.id || null } return this.libraryFilterData[libraryId].authors.find((au) => au.name === authorName)?.id || null } diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 99b977637b..7a46b9b866 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -21,6 +21,11 @@ const naturalSort = createNewSortInstance({ * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/Author')} author + * + * @typedef {RequestWithUser & RequestEntityObject} AuthorControllerRequest */ class AuthorController { @@ -29,13 +34,13 @@ class AuthorController { /** * GET: /api/authors/:id * - * @param {RequestWithUser} req + * @param {AuthorControllerRequest} req * @param {Response} res */ async findOne(req, res) { const include = (req.query.include || '').split(',') - const authorJson = req.author.toJSON() + const authorJson = req.author.toOldJSON() // Used on author landing page to include library items and items grouped in series if (include.includes('items')) { @@ -80,25 +85,30 @@ class AuthorController { /** * PATCH: /api/authors/:id * - * @param {RequestWithUser} req + * @param {AuthorControllerRequest} req * @param {Response} res */ async update(req, res) { - const payload = req.body - let hasUpdated = false - - // author imagePath must be set through other endpoints as of v2.4.5 - if (payload.imagePath !== undefined) { - Logger.warn(`[AuthorController] Updating local author imagePath is not supported`) - delete payload.imagePath + const keysToUpdate = ['name', 'description', 'asin'] + const payload = {} + for (const key in req.body) { + if (keysToUpdate.includes(key) && (typeof req.body[key] === 'string' || req.body[key] === null)) { + payload[key] = req.body[key] + } } + if (!Object.keys(payload).length) { + Logger.error(`[AuthorController] Invalid request payload. No valid keys found`, req.body) + return res.status(400).send('Invalid request payload. No valid keys found') + } + + let hasUpdated = false const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name // Check if author name matches another author and merge the authors let existingAuthor = null if (authorNameUpdate) { - const author = await Database.authorModel.findOne({ + existingAuthor = await Database.authorModel.findOne({ where: { id: { [sequelize.Op.not]: req.author.id @@ -106,7 +116,6 @@ class AuthorController { name: payload.name } }) - existingAuthor = author?.getOldAuthor() } if (existingAuthor) { Logger.info(`[AuthorController] Merging author "${req.author.name}" with "${existingAuthor.name}"`) @@ -143,86 +152,87 @@ class AuthorController { } // Remove old author - await Database.removeAuthor(req.author.id) - SocketAuthority.emitter('author_removed', req.author.toJSON()) + const oldAuthorJSON = req.author.toOldJSON() + await req.author.destroy() + SocketAuthority.emitter('author_removed', oldAuthorJSON) // Update filter data - Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id) + Database.removeAuthorFromFilterData(oldAuthorJSON.libraryId, oldAuthorJSON.id) // Send updated num books for merged author const numBooks = await Database.bookAuthorModel.getCountForAuthor(existingAuthor.id) - SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks)) + SocketAuthority.emitter('author_updated', existingAuthor.toOldJSONExpanded(numBooks)) res.json({ - author: existingAuthor.toJSON(), + author: existingAuthor.toOldJSON(), merged: true }) - } else { - // Regular author update - if (req.author.update(payload)) { - hasUpdated = true - } - - if (hasUpdated) { - req.author.updatedAt = Date.now() + return + } - let numBooksForAuthor = 0 - if (authorNameUpdate) { - const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id) + // Regular author update + req.author.set(payload) + if (req.author.changed()) { + await req.author.save() + hasUpdated = true + } - numBooksForAuthor = allItemsWithAuthor.length - const oldLibraryItems = [] - // Update author name on all books - for (const libraryItem of allItemsWithAuthor) { - libraryItem.media.authors = libraryItem.media.authors.map((au) => { - if (au.id === req.author.id) { - au.name = req.author.name - } - return au - }) - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - oldLibraryItems.push(oldLibraryItem) + if (hasUpdated) { + let numBooksForAuthor = 0 + if (authorNameUpdate) { + const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id) - await libraryItem.saveMetadataFile() - } + numBooksForAuthor = allItemsWithAuthor.length + const oldLibraryItems = [] + // Update author name on all books + for (const libraryItem of allItemsWithAuthor) { + libraryItem.media.authors = libraryItem.media.authors.map((au) => { + if (au.id === req.author.id) { + au.name = req.author.name + } + return au + }) + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) + oldLibraryItems.push(oldLibraryItem) - if (oldLibraryItems.length) { - SocketAuthority.emitter( - 'items_updated', - oldLibraryItems.map((li) => li.toJSONExpanded()) - ) - } - } else { - numBooksForAuthor = await Database.bookAuthorModel.getCountForAuthor(req.author.id) + await libraryItem.saveMetadataFile() } - await Database.updateAuthor(req.author) - SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooksForAuthor)) + if (oldLibraryItems.length) { + SocketAuthority.emitter( + 'items_updated', + oldLibraryItems.map((li) => li.toJSONExpanded()) + ) + } + } else { + numBooksForAuthor = await Database.bookAuthorModel.getCountForAuthor(req.author.id) } - res.json({ - author: req.author.toJSON(), - updated: hasUpdated - }) + SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooksForAuthor)) } + + res.json({ + author: req.author.toOldJSON(), + updated: hasUpdated + }) } /** * DELETE: /api/authors/:id * Remove author from all books and delete * - * @param {RequestWithUser} req + * @param {AuthorControllerRequest} req * @param {Response} res */ async delete(req, res) { Logger.info(`[AuthorController] Removing author "${req.author.name}"`) - await Database.authorModel.removeById(req.author.id) - if (req.author.imagePath) { await CacheManager.purgeImageCache(req.author.id) // Purge cache } - SocketAuthority.emitter('author_removed', req.author.toJSON()) + await req.author.destroy() + + SocketAuthority.emitter('author_removed', req.author.toOldJSON()) // Update filter data Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id) @@ -234,7 +244,7 @@ class AuthorController { * POST: /api/authors/:id/image * Upload author image from web URL * - * @param {RequestWithUser} req + * @param {AuthorControllerRequest} req * @param {Response} res */ async uploadImage(req, res) { @@ -265,13 +275,14 @@ class AuthorController { } req.author.imagePath = result.path - req.author.updatedAt = Date.now() - await Database.authorModel.updateFromOld(req.author) + // imagePath may not have changed, but we still want to update the updatedAt field to bust image cache + req.author.changed('imagePath', true) + await req.author.save() const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id) - SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) + SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks)) res.json({ - author: req.author.toJSON() + author: req.author.toOldJSON() }) } @@ -279,7 +290,7 @@ class AuthorController { * DELETE: /api/authors/:id/image * Remove author image & delete image file * - * @param {RequestWithUser} req + * @param {AuthorControllerRequest} req * @param {Response} res */ async deleteImage(req, res) { @@ -291,19 +302,19 @@ class AuthorController { await CacheManager.purgeImageCache(req.author.id) // Purge cache await CoverManager.removeFile(req.author.imagePath) req.author.imagePath = null - await Database.authorModel.updateFromOld(req.author) + await req.author.save() const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id) - SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) + SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks)) res.json({ - author: req.author.toJSON() + author: req.author.toOldJSON() }) } /** * POST: /api/authors/:id/match * - * @param {RequestWithUser} req + * @param {AuthorControllerRequest} req * @param {Response} res */ async match(req, res) { @@ -342,24 +353,22 @@ class AuthorController { } if (hasUpdates) { - req.author.updatedAt = Date.now() - - await Database.updateAuthor(req.author) + await req.author.save() const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id) - SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) + SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks)) } res.json({ updated: hasUpdates, - author: req.author + author: req.author.toOldJSON() }) } /** * GET: /api/authors/:id/image * - * @param {RequestWithUser} req + * @param {AuthorControllerRequest} req * @param {Response} res */ async getImage(req, res) { @@ -392,7 +401,7 @@ class AuthorController { * @param {NextFunction} next */ async middleware(req, res, next) { - const author = await Database.authorModel.getOldById(req.params.id) + const author = await Database.authorModel.findByPk(req.params.id) if (!author) return res.sendStatus(404) if (req.method == 'DELETE' && !req.user.canDelete) { diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 59e8c181d7..9d6be80ebb 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -887,8 +887,7 @@ class LibraryController { const oldAuthors = [] for (const author of authors) { - const oldAuthor = author.getOldAuthor().toJSON() - oldAuthor.numBooks = author.books.length + const oldAuthor = author.toOldJSONExpanded(author.books.length) oldAuthor.lastFirst = author.lastFirst oldAuthors.push(oldAuthor) } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 9a87f7a798..472f567856 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -151,6 +151,8 @@ class LibraryItemController { * PATCH: /items/:id/media * Update media for a library item. Will create new authors & series when necessary * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -185,6 +187,12 @@ class LibraryItemController { seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) } + let authorsRemoved = [] + if (libraryItem.isBook && mediaPayload.metadata?.authors) { + const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) + authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + } + const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url if (hasUpdates) { libraryItem.updatedAt = Date.now() @@ -205,6 +213,15 @@ class LibraryItemController { Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + + if (authorsRemoved.length) { + // Check remove empty authors + Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) + await this.checkRemoveAuthorsWithNoBooks( + libraryItem.libraryId, + authorsRemoved.map((au) => au.id) + ) + } } res.json({ updated: hasUpdates, @@ -823,7 +840,7 @@ class LibraryItemController { // We actually need to check for Webkit on Apple mobile devices because this issue impacts all browsers on iOS/iPadOS/etc, not just Safari. const isAppleMobileBrowser = ua.device.vendor === 'Apple' && ua.device.type === 'mobile' && ua.engine.name === 'WebKit' if (isAppleMobileBrowser && audioMimeType === AudioMimeType.M4B) { - audioMimeType = 'audio/m4b' + audioMimeType = 'audio/m4b' } res.setHeader('Content-Type', audioMimeType) } diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index 8f810a3307..b4d2f270c2 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -124,6 +124,13 @@ class CacheManager { await this.ensureCachePaths() } + /** + * + * @param {import('express').Response} res + * @param {import('../models/Author')} author + * @param {{ format?: string, width?: number, height?: number }} options + * @returns + */ async handleAuthorCache(res, author, options = {}) { const format = options.format || 'webp' const width = options.width || 400 diff --git a/server/models/Author.js b/server/models/Author.js index a49141d73b..1668d9e766 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -1,7 +1,5 @@ const { DataTypes, Model, where, fn, col } = require('sequelize') -const oldAuthor = require('../objects/entities/Author') - class Author extends Model { constructor(values, options) { super(values, options) @@ -26,69 +24,6 @@ class Author extends Model { this.createdAt } - getOldAuthor() { - return new oldAuthor({ - id: this.id, - asin: this.asin, - name: this.name, - description: this.description, - imagePath: this.imagePath, - libraryId: this.libraryId, - addedAt: this.createdAt.valueOf(), - updatedAt: this.updatedAt.valueOf() - }) - } - - static updateFromOld(oldAuthor) { - const author = this.getFromOld(oldAuthor) - return this.update(author, { - where: { - id: author.id - } - }) - } - - static createFromOld(oldAuthor) { - const author = this.getFromOld(oldAuthor) - return this.create(author) - } - - static createBulkFromOld(oldAuthors) { - const authors = oldAuthors.map(this.getFromOld) - return this.bulkCreate(authors) - } - - static getFromOld(oldAuthor) { - return { - id: oldAuthor.id, - name: oldAuthor.name, - lastFirst: oldAuthor.lastFirst, - asin: oldAuthor.asin, - description: oldAuthor.description, - imagePath: oldAuthor.imagePath, - libraryId: oldAuthor.libraryId - } - } - - static removeById(authorId) { - return this.destroy({ - where: { - id: authorId - } - }) - } - - /** - * Get oldAuthor by id - * @param {string} authorId - * @returns {Promise} - */ - static async getOldById(authorId) { - const author = await this.findByPk(authorId) - if (!author) return null - return author.getOldAuthor() - } - /** * Check if author exists * @param {string} authorId @@ -99,25 +34,22 @@ class Author extends Model { } /** - * Get old author by name and libraryId. name case insensitive + * Get author by name and libraryId. name case insensitive * TODO: Look for authors ignoring punctuation * * @param {string} authorName * @param {string} libraryId - * @returns {Promise} + * @returns {Promise} */ - static async getOldByNameAndLibrary(authorName, libraryId) { - const author = ( - await this.findOne({ - where: [ - where(fn('lower', col('name')), authorName.toLowerCase()), - { - libraryId - } - ] - }) - )?.getOldAuthor() - return author + static async getByNameAndLibrary(authorName, libraryId) { + return this.findOne({ + where: [ + where(fn('lower', col('name')), authorName.toLowerCase()), + { + libraryId + } + ] + }) } /** @@ -213,5 +145,36 @@ class Author extends Model { }) Author.belongsTo(library) } + + toOldJSON() { + return { + id: this.id, + asin: this.asin, + name: this.name, + description: this.description, + imagePath: this.imagePath, + libraryId: this.libraryId, + addedAt: this.createdAt.valueOf(), + updatedAt: this.updatedAt.valueOf() + } + } + + /** + * + * @param {number} numBooks + * @returns + */ + toOldJSONExpanded(numBooks = 0) { + const oldJson = this.toOldJSON() + oldJson.numBooks = numBooks + return oldJson + } + + toJSONMinimal() { + return { + id: this.id, + name: this.name + } + } } module.exports = Author diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 54f85aded9..3f585ee0c7 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -773,7 +773,7 @@ class LibraryItem extends Model { /** * Get book library items for author, optional use user permissions - * @param {oldAuthor} author + * @param {import('./Author')} author * @param {import('./User')} user * @returns {Promise} */ diff --git a/server/objects/entities/Author.js b/server/objects/entities/Author.js deleted file mode 100644 index 3d7c0e3c07..0000000000 --- a/server/objects/entities/Author.js +++ /dev/null @@ -1,101 +0,0 @@ -const Logger = require('../../Logger') -const uuidv4 = require("uuid").v4 -const { checkNamesAreEqual, nameToLastFirst } = require('../../utils/parsers/parseNameString') - -class Author { - constructor(author) { - this.id = null - this.asin = null - this.name = null - this.description = null - this.imagePath = null - this.addedAt = null - this.updatedAt = null - this.libraryId = null - - if (author) { - this.construct(author) - } - } - - construct(author) { - this.id = author.id - this.asin = author.asin - this.name = author.name || '' - this.description = author.description || null - this.imagePath = author.imagePath - this.addedAt = author.addedAt - this.updatedAt = author.updatedAt - this.libraryId = author.libraryId - } - - get lastFirst() { - if (!this.name) return '' - return nameToLastFirst(this.name) - } - - toJSON() { - return { - id: this.id, - asin: this.asin, - name: this.name, - description: this.description, - imagePath: this.imagePath, - addedAt: this.addedAt, - updatedAt: this.updatedAt, - libraryId: this.libraryId - } - } - - toJSONExpanded(numBooks = 0) { - const json = this.toJSON() - json.numBooks = numBooks - return json - } - - toJSONMinimal() { - return { - id: this.id, - name: this.name - } - } - - setData(data, libraryId) { - this.id = uuidv4() - if (!data.name) { - Logger.error(`[Author] setData: Setting author data without a name`, data) - } - this.name = data.name || '' - this.description = data.description || null - this.asin = data.asin || null - this.imagePath = data.imagePath || null - this.addedAt = Date.now() - this.updatedAt = Date.now() - this.libraryId = libraryId - } - - update(payload) { - const json = this.toJSON() - delete json.id - delete json.addedAt - delete json.updatedAt - let hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined && json[key] != payload[key]) { - this[key] = payload[key] - hasUpdates = true - } - } - return hasUpdates - } - - checkNameEquals(name) { - if (!name) return false - if (this.name === null) { - Logger.error(`[Author] Author name is null (${this.id})`) - return false - } - return checkNamesAreEqual(this.name, name) - } -} -module.exports = Author \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e6e5a69427..484377e077 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -1,5 +1,6 @@ const express = require('express') const Path = require('path') +const sequelize = require('sequelize') const Logger = require('../Logger') const Database = require('../Database') @@ -32,7 +33,6 @@ const CustomMetadataProviderController = require('../controllers/CustomMetadataP const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') -const Author = require('../objects/entities/Author') const Series = require('../objects/entities/Series') class ApiRouter { @@ -469,6 +469,54 @@ class ApiRouter { } } + /** + * Remove authors with no books and unset asin, description and imagePath + * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged) + * + * @param {string} libraryId + * @param {string[]} authorIds + * @returns {Promise} + */ + async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) { + if (!authorIds?.length) return + + const bookAuthorsToRemove = ( + await Database.authorModel.findAll({ + where: [ + { + id: authorIds, + asin: { + [sequelize.Op.or]: [null, ''] + }, + description: { + [sequelize.Op.or]: [null, ''] + }, + imagePath: { + [sequelize.Op.or]: [null, ''] + } + }, + sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) + ], + attributes: ['id', 'name'], + raw: true + }) + ).map((au) => ({ id: au.id, name: au.name })) + + if (bookAuthorsToRemove.length) { + await Database.authorModel.destroy({ + where: { + id: bookAuthorsToRemove.map((au) => au.id) + } + }) + bookAuthorsToRemove.forEach(({ id, name }) => { + Database.removeAuthorFromFilterData(libraryId, id) + // TODO: Clients were expecting full author in payload but its unnecessary + SocketAuthority.emitter('author_removed', { id, libraryId }) + Logger.info(`[ApiRouter] Removed author "${name}" with no books`) + }) + } + } + /** * Remove an empty series & close an open RSS feed * @param {import('../models/Series')} series @@ -567,11 +615,13 @@ class ApiRouter { } if (!mediaMetadata.authors[i].id) { - let author = await Database.authorModel.getOldByNameAndLibrary(authorName, libraryId) + let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryId) if (!author) { - author = new Author() - author.setData(mediaMetadata.authors[i], libraryId) - Logger.debug(`[ApiRouter] Created new author "${author.name}"`) + author = await Database.authorModel.create({ + name: authorName, + libraryId + }) + Logger.debug(`[ApiRouter] Creating new author "${author.name}"`) newAuthors.push(author) // Update filter data Database.addAuthorToFilterData(libraryId, author.name, author.id) @@ -584,10 +634,9 @@ class ApiRouter { // Remove authors without an id mediaMetadata.authors = mediaMetadata.authors.filter((au) => !!au.id) if (newAuthors.length) { - await Database.createBulkAuthors(newAuthors) SocketAuthority.emitter( 'authors_added', - newAuthors.map((au) => au.toJSON()) + newAuthors.map((au) => au.toOldJSON()) ) } } diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 4d67248cb4..5508ff187c 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -8,7 +8,6 @@ const { findMatchingEpisodesInFeed, getPodcastFeed } = require('../utils/podcast const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const LibraryScan = require('./LibraryScan') -const Author = require('../objects/entities/Author') const Series = require('../objects/entities/Series') const LibraryScanner = require('./LibraryScanner') const CoverManager = require('../managers/CoverManager') @@ -206,12 +205,13 @@ class Scanner { } const authorPayload = [] for (const authorName of matchData.author) { - let author = await Database.authorModel.getOldByNameAndLibrary(authorName, libraryItem.libraryId) + let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId) if (!author) { - author = new Author() - author.setData({ name: authorName }, libraryItem.libraryId) - await Database.createAuthor(author) - SocketAuthority.emitter('author_added', author.toJSON()) + author = await Database.authorModel.create({ + name: authorName, + libraryId: libraryItem.libraryId + }) + SocketAuthority.emitter('author_added', author.toOldJSON()) // Update filter data Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id) } diff --git a/server/utils/parsers/parseNameString.js b/server/utils/parsers/parseNameString.js index c1f8ab3f5e..741beb0911 100644 --- a/server/utils/parsers/parseNameString.js +++ b/server/utils/parsers/parseNameString.js @@ -42,15 +42,15 @@ module.exports.parse = (nameString) => { var splitNames = [] // Example &LF: Friedman, Milton & Friedman, Rose if (nameString.includes('&')) { - nameString.split('&').forEach((asa) => splitNames = splitNames.concat(asa.split(','))) + nameString.split('&').forEach((asa) => (splitNames = splitNames.concat(asa.split(',')))) } else if (nameString.includes(' and ')) { - nameString.split(' and ').forEach((asa) => splitNames = splitNames.concat(asa.split(','))) + nameString.split(' and ').forEach((asa) => (splitNames = splitNames.concat(asa.split(',')))) } else if (nameString.includes(';')) { - nameString.split(';').forEach((asa) => splitNames = splitNames.concat(asa.split(','))) + nameString.split(';').forEach((asa) => (splitNames = splitNames.concat(asa.split(',')))) } else { splitNames = nameString.split(',') } - if (splitNames.length) splitNames = splitNames.map(a => a.trim()) + if (splitNames.length) splitNames = splitNames.map((a) => a.trim()) var names = [] @@ -84,21 +84,12 @@ module.exports.parse = (nameString) => { } // Filter out names that have no first and last - names = names.filter(n => n.first_name || n.last_name) + names = names.filter((n) => n.first_name || n.last_name) // Set name strings and remove duplicates - const namesArray = [...new Set(names.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name))] + const namesArray = [...new Set(names.map((a) => (a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name)))] return { names: namesArray // Array of first last } } - -module.exports.checkNamesAreEqual = (name1, name2) => { - if (!name1 || !name2) return false - - // e.g. John H. Smith will be equal to John H Smith - name1 = String(name1).toLowerCase().trim().replace(/\./g, '') - name2 = String(name2).toLowerCase().trim().replace(/\./g, '') - return name1 === name2 -} \ No newline at end of file diff --git a/server/utils/queries/authorFilters.js b/server/utils/queries/authorFilters.js index bd4d08925e..675915350f 100644 --- a/server/utils/queries/authorFilters.js +++ b/server/utils/queries/authorFilters.js @@ -73,8 +73,7 @@ module.exports = { }) const authorMatches = [] for (const author of authors) { - const oldAuthor = author.getOldAuthor().toJSON() - oldAuthor.numBooks = author.dataValues.numBooks + const oldAuthor = author.toOldJSONExpanded(author.dataValues.numBooks) authorMatches.push(oldAuthor) } return authorMatches diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 2268ef2111..39c508566d 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -353,7 +353,7 @@ module.exports = { return { authors: authors.map((au) => { const numBooks = au.books.length || 0 - return au.getOldAuthor().toJSONExpanded(numBooks) + return au.toOldJSONExpanded(numBooks) }), count } @@ -409,7 +409,7 @@ module.exports = { /** * Get library items for an author, optional use user permissions - * @param {oldAuthor} author + * @param {import('../../models/Author')} author * @param {import('../../models/User')} user * @param {number} limit * @param {number} offset From 98cd19d440322154843efb81253c1fff29d3c87d Mon Sep 17 00:00:00 2001 From: Nicholas W Date: Sat, 31 Aug 2024 11:35:14 -0700 Subject: [PATCH 037/539] Config issue workflow (#3348) * Intial: issue comments workflow * Update: formatting * Additional common search terms --- .github/workflows/apply_comments.yaml | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/apply_comments.yaml diff --git a/.github/workflows/apply_comments.yaml b/.github/workflows/apply_comments.yaml new file mode 100644 index 0000000000..69a7ce280d --- /dev/null +++ b/.github/workflows/apply_comments.yaml @@ -0,0 +1,55 @@ +name: Add issue comments by label +on: + issues: + types: + - labeled +jobs: + help-wanted: + if: github.event.label.name == 'help wanted' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Help wanted comment + run: gh issue comment "$NUMBER" --body "$BODY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + BODY: > + This issue is not able to be completed due to limited bandwidth or access to the required test hardware. + + This issue is available for anyone to work on. + + + config-issue: + if: github.event.label.name == 'config-issue' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Config issue comment + run: gh issue close "$NUMBER" --reason "not planned" --comment "$BODY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + BODY: > + After reviewing this issue, this appears to be a problem with your setup and not Audiobookshelf. This issue is being closed to keep the issue tracker focused on Audiobookshelf itself. Please reach out on the Audiobookshelf Discord for community support. + + Some common search terms to help you find the solution to your problem: + - Reverse proxy + - Enabling websockets + - SSL (https vs http) + - Configuring a static IP + - `localhost` versus IP address + - hairpin NAT + - VPN + - firewall ports + - public versus private network + - bridge versus host mode + - Docker networking + - DNS (such as EAI_AGAIN errors) + + After you have followed these steps, please post the solution or steps you followed to fix the problem to help others in the future, or show that it is a problem with Audiobookshelf so we can reopen the issue. + From ba23d258e7f2acbf400f64d4ad5f536266889fbf Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 31 Aug 2024 21:32:40 +0200 Subject: [PATCH 038/539] Translations update from Hosted Weblate (#3342) * Translated using Weblate (Croatian) Currently translated at 69.8% (611 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ * Translated using Weblate (Croatian) Currently translated at 92.1% (806 of 875 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ * Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ * Translated using Weblate (German) Currently translated at 94.5% (921 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ * Translated using Weblate (Spanish) Currently translated at 90.9% (886 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ * Translated using Weblate (French) Currently translated at 90.8% (885 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ * Translated using Weblate (Croatian) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ * Translated using Weblate (French) Currently translated at 92.4% (900 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ * Translated using Weblate (French) Currently translated at 93.5% (911 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --------- Co-authored-by: biuklija Co-authored-by: Mario Co-authored-by: gallegonovato Co-authored-by: Charlie --- client/strings/bg.json | 8 - client/strings/bn.json | 8 - client/strings/cs.json | 8 - client/strings/da.json | 8 - client/strings/de.json | 62 +- client/strings/es.json | 27 +- client/strings/et.json | 8 - client/strings/fi.json | 2 - client/strings/fr.json | 64 +- client/strings/gu.json | 8 - client/strings/he.json | 8 - client/strings/hi.json | 8 - client/strings/hr.json | 1164 +++++++++++++++++++++---------------- client/strings/hu.json | 8 - client/strings/it.json | 8 - client/strings/lt.json | 8 - client/strings/nl.json | 8 - client/strings/no.json | 8 - client/strings/pl.json | 8 - client/strings/pt-br.json | 8 - client/strings/ru.json | 8 - client/strings/sv.json | 8 - client/strings/uk.json | 8 - client/strings/vi-vn.json | 8 - client/strings/zh-cn.json | 8 - client/strings/zh-tw.json | 8 - 26 files changed, 780 insertions(+), 707 deletions(-) diff --git a/client/strings/bg.json b/client/strings/bg.json index 3bcdea4825..271f359ee9 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Добави {0} Книги в Колекция", "LabelAddToPlaylist": "Добави в Плейлист", "LabelAddToPlaylistBatch": "Добави {0} Елемент в Плейлист", - "LabelAdded": "Добавени", "LabelAddedAt": "Добавени На", "LabelAdminUsersOnly": "Само за Администратори", "LabelAll": "Всички", @@ -692,7 +691,6 @@ "MessageNoSeries": "Няма Серии", "MessageNoTags": "Няма Тагове", "MessageNoTasksRunning": "Няма вършещи се задачи", - "MessageNoUpdateNecessary": "Не е необходимо обновяване", "MessageNoUpdatesWereNecessary": "Не бяха необходими обновления", "MessageNoUserPlaylists": "Няма плейлисти на потребителя", "MessageNotYetImplemented": "Още не е изпълнено", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Търсене на Епизоди...", "ToastAccountUpdateFailed": "Неуспешно обновяване на акаунта", "ToastAccountUpdateSuccess": "Успешно обновяване на акаунта", - "ToastAuthorImageRemoveFailed": "Неуспешно премахване на авторска снимка", "ToastAuthorImageRemoveSuccess": "Авторската снимка е премахната", "ToastAuthorUpdateFailed": "Неуспешно обновяване на автора", "ToastAuthorUpdateMerged": "Обновяване на автора сливано", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Batch update success", "ToastBookmarkCreateFailed": "Неуспешно създаване на отметка", "ToastBookmarkCreateSuccess": "Отметката е създадена", - "ToastBookmarkRemoveFailed": "Неуспешно премахване на отметка", "ToastBookmarkRemoveSuccess": "Отметката е премахната", "ToastBookmarkUpdateFailed": "Неуспешно обновяване на отметка", "ToastBookmarkUpdateSuccess": "Отметката е обновена", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", - "ToastCollectionItemsRemoveFailed": "Неуспешно премахване на елемент(и) от колекция", "ToastCollectionItemsRemoveSuccess": "Елемент(и) премахнати от колекция", - "ToastCollectionRemoveFailed": "Неуспешно премахване на колекция", "ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionUpdateFailed": "Неуспешно обновяване на колекция", "ToastCollectionUpdateSuccess": "Колекцията е обновена", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", "ToastItemDetailsUpdateFailed": "Неуспешно обновяване на детайли на елемент", "ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени", - "ToastItemDetailsUpdateUnneeded": "Не са необходими обновления на детайлите на елемента", "ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като завършено", "ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен", "ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като незавършено", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена", "ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист", "ToastPlaylistCreateSuccess": "Плейлистът е създаден", - "ToastPlaylistRemoveFailed": "Неуспешно премахване на плейлист", "ToastPlaylistRemoveSuccess": "Плейлистът е премахнат", "ToastPlaylistUpdateFailed": "Неуспешно обновяване на плейлист", "ToastPlaylistUpdateSuccess": "Плейлистът е обновен", diff --git a/client/strings/bn.json b/client/strings/bn.json index 09db5382fe..850188160e 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "সংগ্রহে {0}টি বই যোগ করুন", "LabelAddToPlaylist": "প্লেলিস্টে যোগ করুন", "LabelAddToPlaylistBatch": "প্লেলিস্টে {0}টি আইটেম যোগ করুন", - "LabelAdded": "যোগ করা হয়েছে", "LabelAddedAt": "এতে যোগ করা হয়েছে", "LabelAdminUsersOnly": "শুধু অ্যাডমিন ব্যবহারকারী", "LabelAll": "সব", @@ -692,7 +691,6 @@ "MessageNoSeries": "কোন সিরিজ নেই", "MessageNoTags": "কোন ট্যাগ নেই", "MessageNoTasksRunning": "কোন টাস্ক চলছে না", - "MessageNoUpdateNecessary": "কোন আপডেটের প্রয়োজন নেই", "MessageNoUpdatesWereNecessary": "কোন আপডেটের প্রয়োজন ছিল না", "MessageNoUserPlaylists": "আপনার কোনো প্লেলিস্ট নেই", "MessageNotYetImplemented": "এখনও বাস্তবায়িত হয়নি", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "অনুসন্ধান পর্ব..", "ToastAccountUpdateFailed": "অ্যাকাউন্ট আপডেট করতে ব্যর্থ", "ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে", - "ToastAuthorImageRemoveFailed": "ছবি সরাতে ব্যর্থ", "ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে", "ToastAuthorUpdateFailed": "লেখক আপডেট করতে ব্যর্থ", "ToastAuthorUpdateMerged": "লেখক একত্রিত হয়েছে", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য", "ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ", "ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে", - "ToastBookmarkRemoveFailed": "বুকমার্ক সরাতে ব্যর্থ", "ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে", "ToastBookmarkUpdateFailed": "বুকমার্ক আপডেট করতে ব্যর্থ", "ToastBookmarkUpdateSuccess": "বুকমার্ক আপডেট করা হয়েছে", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে", "ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে", - "ToastCollectionItemsRemoveFailed": "সংগ্রহ থেকে আইটেম(গুলি) সরাতে ব্যর্থ", "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে", - "ToastCollectionRemoveFailed": "সংগ্রহ সরাতে ব্যর্থ", "ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে", "ToastCollectionUpdateFailed": "সংগ্রহ আপডেট করতে ব্যর্থ", "ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", "ToastItemDetailsUpdateFailed": "আইটেমের বিবরণ আপডেট করতে ব্যর্থ", "ToastItemDetailsUpdateSuccess": "আইটেমের বিবরণ আপডেট করা হয়েছে", - "ToastItemDetailsUpdateUnneeded": "আইটেমের বিবরণের জন্য কোন আপডেটের প্রয়োজন নেই", "ToastItemMarkedAsFinishedFailed": "সমাপ্ত হিসাবে চিহ্নিত করতে ব্যর্থ", "ToastItemMarkedAsFinishedSuccess": "আইটেম সমাপ্ত হিসাবে চিহ্নিত", "ToastItemMarkedAsNotFinishedFailed": "সমাপ্ত হয়নি হিসাবে চিহ্নিত করতে ব্যর্থ", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে", "ToastPlaylistCreateFailed": "প্লেলিস্ট তৈরি করতে ব্যর্থ", "ToastPlaylistCreateSuccess": "প্লেলিস্ট তৈরি করা হয়েছে", - "ToastPlaylistRemoveFailed": "প্লেলিস্ট সরাতে ব্যর্থ", "ToastPlaylistRemoveSuccess": "প্লেলিস্ট সরানো হয়েছে", "ToastPlaylistUpdateFailed": "প্লেলিস্ট আপডেট করতে ব্যর্থ", "ToastPlaylistUpdateSuccess": "প্লেলিস্ট আপডেট করা হয়েছে", diff --git a/client/strings/cs.json b/client/strings/cs.json index c1e3bbec3a..e0517b3c85 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -205,7 +205,6 @@ "LabelAddToCollectionBatch": "Přidat {0} knihy do kolekce", "LabelAddToPlaylist": "Přidat do seznamu přehrávání", "LabelAddToPlaylistBatch": "Přidat {0} položky do seznamu přehrávání", - "LabelAdded": "Přidáno", "LabelAddedAt": "Přidáno v", "LabelAdminUsersOnly": "Pouze administrátoři", "LabelAll": "Vše", @@ -720,7 +719,6 @@ "MessageNoSeries": "Žádné série", "MessageNoTags": "Žádné značky", "MessageNoTasksRunning": "Nejsou spuštěny žádné úlohy", - "MessageNoUpdateNecessary": "Není nutná žádná aktualizace", "MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace", "MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb", "MessageNotYetImplemented": "Ještě není implementováno", @@ -784,7 +782,6 @@ "StatsYearInReview": "ROK V PŘEHLEDU", "ToastAccountUpdateFailed": "Aktualizace účtu se nezdařila", "ToastAccountUpdateSuccess": "Účet aktualizován", - "ToastAuthorImageRemoveFailed": "Nepodařilo se odstranit obrázek", "ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn", "ToastAuthorUpdateFailed": "Aktualizace autora se nezdařila", "ToastAuthorUpdateMerged": "Autor sloučen", @@ -801,7 +798,6 @@ "ToastBatchUpdateSuccess": "Dávková aktualizace proběhla úspěšně", "ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo", "ToastBookmarkCreateSuccess": "Přidána záložka", - "ToastBookmarkRemoveFailed": "Nepodařilo se odstranit záložku", "ToastBookmarkRemoveSuccess": "Záložka odstraněna", "ToastBookmarkUpdateFailed": "Aktualizace záložky se nezdařila", "ToastBookmarkUpdateSuccess": "Záložka aktualizována", @@ -809,9 +805,7 @@ "ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna", "ToastChaptersHaveErrors": "Kapitoly obsahují chyby", "ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy", - "ToastCollectionItemsRemoveFailed": "Nepodařilo se odstranit položky z kolekce", "ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce", - "ToastCollectionRemoveFailed": "Nepodařilo se odstranit kolekci", "ToastCollectionRemoveSuccess": "Kolekce odstraněna", "ToastCollectionUpdateFailed": "Aktualizace kolekce se nezdařila", "ToastCollectionUpdateSuccess": "Kolekce aktualizována", @@ -823,7 +817,6 @@ "ToastItemCoverUpdateSuccess": "Obálka předmětu byl aktualizována", "ToastItemDetailsUpdateFailed": "Nepodařilo se aktualizovat podrobnosti o položce", "ToastItemDetailsUpdateSuccess": "Podrobnosti o položce byly aktualizovány", - "ToastItemDetailsUpdateUnneeded": "Podrobnosti o položce nejsou potřeba aktualizovat", "ToastItemMarkedAsFinishedFailed": "Nepodařilo se označit jako dokončené", "ToastItemMarkedAsFinishedSuccess": "Položka označena jako dokončená", "ToastItemMarkedAsNotFinishedFailed": "Nepodařilo se označit jako nedokončené", @@ -838,7 +831,6 @@ "ToastLibraryUpdateSuccess": "Knihovna \"{0}\" aktualizována", "ToastPlaylistCreateFailed": "Vytvoření seznamu přehrávání se nezdařilo", "ToastPlaylistCreateSuccess": "Seznam přehrávání vytvořen", - "ToastPlaylistRemoveFailed": "Nepodařilo se odstranit seznamu přehrávání", "ToastPlaylistRemoveSuccess": "Seznam přehrávání odstraněn", "ToastPlaylistUpdateFailed": "Aktualizace seznamu přehrávání se nezdařila", "ToastPlaylistUpdateSuccess": "Seznam přehrávání aktualizován", diff --git a/client/strings/da.json b/client/strings/da.json index 9dfbd8f240..7e9fa88cb0 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling", "LabelAddToPlaylist": "Tilføj til Afspilningsliste", "LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste", - "LabelAdded": "Tilføjet", "LabelAddedAt": "Tilføjet Kl.", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", @@ -692,7 +691,6 @@ "MessageNoSeries": "Ingen serier", "MessageNoTags": "Ingen tags", "MessageNoTasksRunning": "Ingen opgaver kører", - "MessageNoUpdateNecessary": "Ingen opdatering nødvendig", "MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige", "MessageNoUserPlaylists": "Du har ingen afspilningslister", "MessageNotYetImplemented": "Endnu ikke implementeret", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Søg efter episode..", "ToastAccountUpdateFailed": "Mislykkedes opdatering af konto", "ToastAccountUpdateSuccess": "Konto opdateret", - "ToastAuthorImageRemoveFailed": "Mislykkedes fjernelse af forfatterbillede", "ToastAuthorImageRemoveSuccess": "Forfatterbillede fjernet", "ToastAuthorUpdateFailed": "Mislykkedes opdatering af forfatter", "ToastAuthorUpdateMerged": "Forfatter fusioneret", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Batchopdatering lykkedes", "ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke", "ToastBookmarkCreateSuccess": "Bogmærke tilføjet", - "ToastBookmarkRemoveFailed": "Mislykkedes fjernelse af bogmærke", "ToastBookmarkRemoveSuccess": "Bogmærke fjernet", "ToastBookmarkUpdateFailed": "Mislykkedes opdatering af bogmærke", "ToastBookmarkUpdateSuccess": "Bogmærke opdateret", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Kapitler har fejl", "ToastChaptersMustHaveTitles": "Kapitler skal have titler", - "ToastCollectionItemsRemoveFailed": "Mislykkedes fjernelse af element(er) fra samlingen", "ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen", - "ToastCollectionRemoveFailed": "Mislykkedes fjernelse af samling", "ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionUpdateFailed": "Mislykkedes opdatering af samling", "ToastCollectionUpdateSuccess": "Samling opdateret", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Varens omslag opdateret", "ToastItemDetailsUpdateFailed": "Mislykkedes opdatering af varedetaljer", "ToastItemDetailsUpdateSuccess": "Varedetaljer opdateret", - "ToastItemDetailsUpdateUnneeded": "Ingen opdateringer er nødvendige for varedetaljer", "ToastItemMarkedAsFinishedFailed": "Mislykkedes markering som afsluttet", "ToastItemMarkedAsFinishedSuccess": "Vare markeret som afsluttet", "ToastItemMarkedAsNotFinishedFailed": "Mislykkedes markering som ikke afsluttet", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" opdateret", "ToastPlaylistCreateFailed": "Mislykkedes oprettelse af afspilningsliste", "ToastPlaylistCreateSuccess": "Afspilningsliste oprettet", - "ToastPlaylistRemoveFailed": "Mislykkedes fjernelse af afspilningsliste", "ToastPlaylistRemoveSuccess": "Afspilningsliste fjernet", "ToastPlaylistUpdateFailed": "Mislykkedes opdatering af afspilningsliste", "ToastPlaylistUpdateSuccess": "Afspilningsliste opdateret", diff --git a/client/strings/de.json b/client/strings/de.json index 1c12a29abf..6aadf856db 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Wähle eine Datei", "ButtonClearFilter": "Filter löschen", "ButtonCloseFeed": "Feed schließen", + "ButtonCloseSession": "Offene Session schließen", "ButtonCollections": "Sammlungen", "ButtonConfigureScanner": "Scannereinstellungen", "ButtonCreate": "Erstellen", @@ -28,6 +29,9 @@ "ButtonEdit": "Bearbeiten", "ButtonEditChapters": "Kapitel bearbeiten", "ButtonEditPodcast": "Podcast bearbeiten", + "ButtonEnable": "Aktivieren", + "ButtonFireAndFail": "Abfeuern und versagen", + "ButtonFireOnTest": "Test-Event abfeuern", "ButtonForceReScan": "Komplett-Scan (alle Medien)", "ButtonFullPath": "Vollständiger Pfad", "ButtonHide": "Ausblenden", @@ -56,6 +60,7 @@ "ButtonPlaylists": "Wiedergabelisten", "ButtonPrevious": "Zurück", "ButtonPreviousChapter": "Vorheriges Kapitel", + "ButtonProbeAudioFile": "Audiodatei untersuchen", "ButtonPurgeAllCache": "Cache leeren", "ButtonPurgeItemsCache": "Lösche Medien-Cache", "ButtonQueueAddItem": "Zur Warteschlange hinzufügen", @@ -93,6 +98,7 @@ "ButtonStats": "Statistiken", "ButtonSubmit": "Ok", "ButtonTest": "Test", + "ButtonUnlinkOpedId": "OpenID trennen", "ButtonUpload": "Hochladen", "ButtonUploadBackup": "Sicherung hochladen", "ButtonUploadCover": "Titelbild hochladen", @@ -105,6 +111,7 @@ "ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuche den Titel und oder den Autor zu aktualisieren", "ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden", "HeaderAccount": "Konto", + "HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen", "HeaderAdvanced": "Erweitert", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAudioTracks": "Audiodateien", @@ -150,6 +157,8 @@ "HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderNewAccount": "Neues Konto", "HeaderNewLibrary": "Neue Bibliothek", + "HeaderNotificationCreate": "Benachrichtigung erstellen", + "HeaderNotificationUpdate": "Benachrichtigung updaten", "HeaderNotifications": "Benachrichtigungen", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", "HeaderOpenRSSFeed": "RSS-Feed öffnen", @@ -206,8 +215,8 @@ "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu", "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", - "LabelAdded": "Hinzugefügt", "LabelAddedAt": "Hinzugefügt am", + "LabelAddedDate": "Hinzugefügt {0}", "LabelAdminUsersOnly": "Nur Admin Benutzer", "LabelAll": "Alle", "LabelAllUsers": "Alle Benutzer", @@ -298,6 +307,7 @@ "LabelEpisode": "Episode", "LabelEpisodeTitle": "Episodentitel", "LabelEpisodeType": "Episodentyp", + "LabelEpisodes": "Episoden", "LabelExample": "Beispiel", "LabelExpandSeries": "Serie ausklappen", "LabelExpandSubSeries": "Unterserie ausklappen", @@ -309,7 +319,9 @@ "LabelFetchingMetadata": "Abholen der Metadaten", "LabelFile": "Datei", "LabelFileBirthtime": "Datei erstellt", + "LabelFileBornDate": "Geboren {0}", "LabelFileModified": "Datei geändert", + "LabelFileModifiedDate": "Geändert {0}", "LabelFilename": "Dateiname", "LabelFilterByUser": "Nach Benutzern filtern", "LabelFindEpisodes": "Episoden suchen", @@ -448,8 +460,10 @@ "LabelPrimaryEbook": "Primäres E-Book", "LabelProgress": "Fortschritt", "LabelProvider": "Anbieter", + "LabelProviderAuthorizationValue": "Autorisierungsheader-Wert", "LabelPubDate": "Veröffentlichungsdatum", "LabelPublishYear": "Jahr", + "LabelPublishedDate": "Veröffentlicht {0}", "LabelPublisher": "Herausgeber", "LabelPublishers": "Herausgeber", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", @@ -595,6 +609,7 @@ "LabelUnabridged": "Ungekürzt", "LabelUndo": "Rückgängig machen", "LabelUnknown": "Unbekannt", + "LabelUnknownPublishDate": "Unbekanntes Veröffentlichungsdatum", "LabelUpdateCover": "Titelbild aktualisieren", "LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird", "LabelUpdateDetails": "Details aktualisieren", @@ -643,16 +658,22 @@ "MessageCheckingCron": "Überprüfe Cron...", "MessageConfirmCloseFeed": "Feed wird geschlossen! Bist du dir sicher?", "MessageConfirmDeleteBackup": "Sicherung für {0} wird gelöscht! Bist du dir sicher?", + "MessageConfirmDeleteDevice": "Möchtest Du das E-Reader-Gerät „{0}“ wirklich löschen?", "MessageConfirmDeleteFile": "Datei wird vom System gelöscht! Bist du dir sicher?", "MessageConfirmDeleteLibrary": "Bibliothek \"{0}\" wird dauerhaft gelöscht! Bist du dir sicher?", "MessageConfirmDeleteLibraryItem": "Bibliothekselement wird aus der Datenbank + Festplatte gelöscht? Bist du dir sicher?", "MessageConfirmDeleteLibraryItems": "{0} Bibliothekselemente werden aus der Datenbank + Festplatte gelöscht? Bist du dir sicher?", + "MessageConfirmDeleteMetadataProvider": "Möchtest du den benutzerdefinierten Metadatenanbieter \"{0}\" wirklich löschen?", + "MessageConfirmDeleteNotification": "Möchtest du diese Benachrichtigung wirklich löschen?", "MessageConfirmDeleteSession": "Sitzung wird gelöscht! Bist du dir sicher?", "MessageConfirmForceReScan": "Scanvorgang erzwingen! Bist du dir sicher?", "MessageConfirmMarkAllEpisodesFinished": "Alle Episoden werden als abgeschlossen markiert! Bist du dir sicher?", "MessageConfirmMarkAllEpisodesNotFinished": "Alle Episoden werden als nicht abgeschlossen markiert! Bist du dir sicher?", + "MessageConfirmMarkItemFinished": "Möchtest du \"{0}\" wirklich als fertig markieren?", + "MessageConfirmMarkItemNotFinished": "Möchtest du \"{0}\" wirklich als nicht fertig markieren?", "MessageConfirmMarkSeriesFinished": "Alle Medien dieser Reihe werden als abgeschlossen markiert! Bist du dir sicher?", "MessageConfirmMarkSeriesNotFinished": "Alle Medien dieser Reihe werden als nicht abgeschlossen markiert! Bist du dir sicher?", + "MessageConfirmNotificationTestTrigger": "Diese Benachrichtigung mit Testdaten abfeuern?", "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter /metadata/cache/items gelöscht.
Bist du dir sicher?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt.

Möchtest du fortfahren?", @@ -671,7 +692,9 @@ "MessageConfirmRenameTag": "Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Bist du dir sicher?", "MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.", "MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".", + "MessageConfirmResetProgress": "Möchtest du Ihren Fortschritt wirklich zurücksetzen?", "MessageConfirmSendEbookToDevice": "{0} E-Book \"{1}\" wird auf das Gerät \"{2}\" gesendet! Bist du dir sicher?", + "MessageConfirmUnlinkOpenId": "Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?", "MessageDownloadingEpisode": "Episode wird heruntergeladen", "MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge", "MessageEmbedFailed": "Einbetten fehlgeschlagen!", @@ -706,6 +729,7 @@ "MessageNoCollections": "Keine Sammlungen", "MessageNoCoversFound": "Keine Titelbilder gefunden", "MessageNoDescription": "Keine Beschreibung", + "MessageNoDevices": "Keine Geräte", "MessageNoDownloadsInProgress": "Derzeit keine Downloads in Arbeit", "MessageNoDownloadsQueued": "Keine Downloads in der Warteschlange", "MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden", @@ -725,7 +749,6 @@ "MessageNoSeries": "Keine Serien", "MessageNoTags": "Keine Tags", "MessageNoTasksRunning": "Keine laufenden Aufgaben", - "MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden", "MessageNotYetImplemented": "Noch nicht implementiert", @@ -734,6 +757,7 @@ "MessagePauseChapter": "Kapitelwiedergabe pausieren", "MessagePlayChapter": "Kapitelanfang anhören", "MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung", + "MessagePleaseWait": "Bitte warten...", "MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann", "MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.", "MessageRemoveChapter": "Kapitel entfernen", @@ -794,24 +818,32 @@ "StatsYearInReview": "DAS JAHR IM RÜCKBLICK", "ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen", "ToastAccountUpdateSuccess": "Konto aktualisiert", - "ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden", + "ToastAppriseUrlRequired": "Eine Apprise-URL ist notwendig", "ToastAuthorImageRemoveSuccess": "Autorenbild entfernt", + "ToastAuthorNotFound": "Autor \"{0}\" nicht gefunden", + "ToastAuthorRemoveSuccess": "Autor entfernt", + "ToastAuthorSearchNotFound": "Autor nicht gefunden", "ToastAuthorUpdateFailed": "Aktualisierung des Autors fehlgeschlagen", "ToastAuthorUpdateMerged": "Autor zusammengeführt", "ToastAuthorUpdateSuccess": "Autor aktualisiert", "ToastAuthorUpdateSuccessNoImageFound": "Autor aktualisiert (kein Bild gefunden)", + "ToastBackupAppliedSuccess": "Backup anwenden", "ToastBackupCreateFailed": "Sicherung konnte nicht erstellt werden", "ToastBackupCreateSuccess": "Sicherung erstellt", "ToastBackupDeleteFailed": "Sicherung konnte nicht gelöscht werden", "ToastBackupDeleteSuccess": "Sicherung gelöscht", + "ToastBackupInvalidMaxKeep": "Ungültige Anzahl aufzubewahrender Backups", + "ToastBackupInvalidMaxSize": "Ungültige maximale Backupgröße", + "ToastBackupPathUpdateFailed": "Der Backuppfad konnte nicht aktualisiert werden", "ToastBackupRestoreFailed": "Sicherung konnte nicht wiederhergestellt werden", "ToastBackupUploadFailed": "Sicherung konnte nicht hochgeladen werden", "ToastBackupUploadSuccess": "Sicherung hochgeladen", + "ToastBatchDeleteFailed": "Batch-Löschen fehlgeschlagen", + "ToastBatchDeleteSuccess": "Batch-Löschung erfolgreich", "ToastBatchUpdateFailed": "Stapelaktualisierung fehlgeschlagen", "ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich", "ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden", "ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt", - "ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht entfernt werden", "ToastBookmarkRemoveSuccess": "Lesezeichen entfernt", "ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen", "ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert", @@ -819,21 +851,33 @@ "ToastCachePurgeSuccess": "Cache geleert", "ToastChaptersHaveErrors": "Kapitel sind fehlerhaft", "ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen", - "ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung", + "ToastChaptersRemoved": "Kapitel entfernt", + "ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen", + "ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt", "ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt", - "ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden", "ToastCollectionRemoveSuccess": "Sammlung entfernt", "ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert", + "ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen", "ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden", "ToastDeleteFileSuccess": "Datei gelöscht", + "ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden", + "ToastDeviceNameAlreadyExists": "E-Reader-Gerät mit diesem Namen existiert bereits", + "ToastDeviceTestEmailFailed": "Senden der Test-E-Mail fehlgeschlagen", + "ToastDeviceTestEmailSuccess": "Test-E-Mail versand", + "ToastDeviceUpdateFailed": "Das Gerät konnte nicht aktualisiert werden", + "ToastEmailSettingsUpdateFailed": "E-Mail-Einstellungen konnten nicht aktualisiert werden", + "ToastEmailSettingsUpdateSuccess": "E-Mail-Einstellungen aktualisiert", + "ToastEncodeCancelFailed": "Das Encoding konnte nicht abgebrochen werden", + "ToastEncodeCancelSucces": "Encoding abgebrochen", + "ToastEpisodeDownloadQueueClearFailed": "Warteschlange konnte nicht gelöscht werden", + "ToastEpisodeDownloadQueueClearSuccess": "Warteschlange für Episoden-Downloads gelöscht", "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", "ToastFailedToLoadData": "Daten laden fehlgeschlagen", "ToastItemCoverUpdateFailed": "Fehler bei der Aktualisierung des Titelbildes", "ToastItemCoverUpdateSuccess": "Titelbild aktualisiert", "ToastItemDetailsUpdateFailed": "Fehler bei der Aktualisierung der Artikeldetails", "ToastItemDetailsUpdateSuccess": "Artikeldetails aktualisiert", - "ToastItemDetailsUpdateUnneeded": "Keine Aktualisierung für die Artikeldetails erforderlich", "ToastItemMarkedAsFinishedFailed": "Fehler bei der Markierung des Mediums als \"Beendet\"", "ToastItemMarkedAsFinishedSuccess": "Medium als \"Beendet\" markiert", "ToastItemMarkedAsNotFinishedFailed": "Fehler bei der Markierung des Mediums als \"Nicht Beendet\"", @@ -848,12 +892,14 @@ "ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert", "ToastPlaylistCreateFailed": "Erstellen der Wiedergabeliste fehlgeschlagen", "ToastPlaylistCreateSuccess": "Wiedergabeliste erstellt", - "ToastPlaylistRemoveFailed": "Löschen der Wiedergabeliste fehlgeschlagen", "ToastPlaylistRemoveSuccess": "Wiedergabeliste gelöscht", "ToastPlaylistUpdateFailed": "Aktualisieren der Wiedergabeliste fehlgeschlagen", "ToastPlaylistUpdateSuccess": "Wiedergabeliste aktualisiert", "ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden", "ToastPodcastCreateSuccess": "Podcast erstellt", + "ToastProviderCreatedSuccess": "Neuer Anbieter hinzugefügt", + "ToastProviderNameAndUrlRequired": "Name und URL notwendig", + "ToastProviderRemoveSuccess": "Anbieter entfernt", "ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden", "ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen", "ToastRemoveItemFromCollectionFailed": "Löschen des Mediums aus der Sammlung fehlgeschlagen", diff --git a/client/strings/es.json b/client/strings/es.json index 0d13985e26..5800e965dd 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Escoger un Archivo", "ButtonClearFilter": "Quitar filtros", "ButtonCloseFeed": "Cerrar fuente", + "ButtonCloseSession": "Cerrar la sesión abierta", "ButtonCollections": "Colecciones", "ButtonConfigureScanner": "Configurar Escáner", "ButtonCreate": "Crear", @@ -28,6 +29,7 @@ "ButtonEdit": "Editar", "ButtonEditChapters": "Editar Capítulo", "ButtonEditPodcast": "Editar Podcast", + "ButtonEnable": "Permitir", "ButtonForceReScan": "Forzar Re-Escaneo", "ButtonFullPath": "Ruta de Acceso Completa", "ButtonHide": "Esconder", @@ -56,6 +58,7 @@ "ButtonPlaylists": "Listas de reproducción", "ButtonPrevious": "Anterior", "ButtonPreviousChapter": "Capítulo Anterior", + "ButtonProbeAudioFile": "Examinar archivo de audio", "ButtonPurgeAllCache": "Purgar Todo el Cache", "ButtonPurgeItemsCache": "Purgar Elementos de Cache", "ButtonQueueAddItem": "Agregar a la Fila", @@ -93,6 +96,7 @@ "ButtonStats": "Estadísticas", "ButtonSubmit": "Enviar", "ButtonTest": "Prueba", + "ButtonUnlinkOpedId": "Desvincular OpenID", "ButtonUpload": "Subir", "ButtonUploadBackup": "Subir Respaldo", "ButtonUploadCover": "Subir Portada", @@ -105,6 +109,7 @@ "ErrorUploadFetchMetadataNoResults": "No se pudo obtener metadatos - Intenta actualizar el título y/o autor", "ErrorUploadLacksTitle": "Se debe tener título", "HeaderAccount": "Cuenta", + "HeaderAddCustomMetadataProvider": "Agregar proveedor de metadatos personalizado", "HeaderAdvanced": "Avanzado", "HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise", "HeaderAudioTracks": "Pistas de audio", @@ -150,6 +155,7 @@ "HeaderMetadataToEmbed": "Metadatos para Insertar", "HeaderNewAccount": "Nueva Cuenta", "HeaderNewLibrary": "Nueva Biblioteca", + "HeaderNotificationCreate": "Crear notificación", "HeaderNotifications": "Notificaciones", "HeaderOpenIDConnectAuthentication": "Autenticación OpenID Connect", "HeaderOpenRSSFeed": "Abrir fuente RSS", @@ -206,8 +212,8 @@ "LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección", "LabelAddToPlaylist": "Añadido a la lista de reproducción", "LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción", - "LabelAdded": "Añadido", "LabelAddedAt": "Añadido", + "LabelAddedDate": "Añadido {0}", "LabelAdminUsersOnly": "Solamente usuarios administradores", "LabelAll": "Todos", "LabelAllUsers": "Todos los Usuarios", @@ -298,6 +304,7 @@ "LabelEpisode": "Episodio", "LabelEpisodeTitle": "Titulo de Episodio", "LabelEpisodeType": "Tipo de Episodio", + "LabelEpisodes": "Episodios", "LabelExample": "Ejemplo", "LabelExpandSeries": "Ampliar serie", "LabelExpandSubSeries": "Expandir la subserie", @@ -309,7 +316,9 @@ "LabelFetchingMetadata": "Obteniendo metadatos", "LabelFile": "Archivo", "LabelFileBirthtime": "Archivo creado en", + "LabelFileBornDate": "Creado {0}", "LabelFileModified": "Archivo modificado", + "LabelFileModifiedDate": "Modificado {0}", "LabelFilename": "Nombre del archivo", "LabelFilterByUser": "Filtrar por Usuario", "LabelFindEpisodes": "Buscar Episodio", @@ -450,6 +459,7 @@ "LabelProvider": "Proveedor", "LabelPubDate": "Fecha de publicación", "LabelPublishYear": "Año de publicación", + "LabelPublishedDate": "Publicado {0}", "LabelPublisher": "Editor", "LabelPublishers": "Editores", "LabelRSSFeedCustomOwnerEmail": "Correo electrónico de dueño personalizado", @@ -595,6 +605,7 @@ "LabelUnabridged": "No Abreviado", "LabelUndo": "Deshacer", "LabelUnknown": "Desconocido", + "LabelUnknownPublishDate": "Fecha de publicación desconocida", "LabelUpdateCover": "Actualizar Portada", "LabelUpdateCoverHelp": "Permitir sobrescribir las portadas existentes para los libros seleccionados cuando se encuentra una coincidencia", "LabelUpdateDetails": "Actualizar Detalles", @@ -643,16 +654,22 @@ "MessageCheckingCron": "Revisando cron...", "MessageConfirmCloseFeed": "Está seguro de que desea cerrar esta fuente?", "MessageConfirmDeleteBackup": "¿Está seguro de que desea eliminar el respaldo {0}?", + "MessageConfirmDeleteDevice": "¿Estás seguro de que deseas eliminar el lector electrónico \"{0}\"?", "MessageConfirmDeleteFile": "Esto eliminará el archivo de su sistema de archivos. ¿Está seguro?", "MessageConfirmDeleteLibrary": "¿Está seguro de que desea eliminar permanentemente la biblioteca \"{0}\"?", "MessageConfirmDeleteLibraryItem": "Esto removerá la librería de la base de datos y archivos en tu sistema. ¿Estás seguro?", "MessageConfirmDeleteLibraryItems": "Esto removerá {0} elemento(s) de la librería en base de datos y archivos en tu sistema. ¿Estás seguro?", + "MessageConfirmDeleteMetadataProvider": "¿Estás seguro de que deseas eliminar el proveedor de metadatos personalizado \"{0}\"?", + "MessageConfirmDeleteNotification": "¿Estás seguro de que deseas eliminar esta notificación?", "MessageConfirmDeleteSession": "¿Está seguro de que desea eliminar esta sesión?", "MessageConfirmForceReScan": "¿Está seguro de que desea forzar un re-escaneo?", "MessageConfirmMarkAllEpisodesFinished": "¿Está seguro de que desea marcar todos los episodios como terminados?", "MessageConfirmMarkAllEpisodesNotFinished": "¿Está seguro de que desea marcar todos los episodios como no terminados?", + "MessageConfirmMarkItemFinished": "¿Estás seguro de que deseas marcar \"{0}\" como terminado?", + "MessageConfirmMarkItemNotFinished": "¿Estás seguro de que deseas marcar \"{0}\" como no acabado?", "MessageConfirmMarkSeriesFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como terminados?", "MessageConfirmMarkSeriesNotFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como no terminados?", + "MessageConfirmNotificationTestTrigger": "¿Activar esta notificación con datos de prueba?", "MessageConfirmPurgeCache": "Purgar el caché eliminará el directorio completo ubicado en /metadata/cache.

¿Está seguro que desea eliminar el directorio del caché?", "MessageConfirmPurgeItemsCache": "Purgar la caché de los elementos eliminará todo el directorio /metadata/cache/items.
¿Estás seguro?", "MessageConfirmQuickEmbed": "¡Advertencia! La integración rápida no realiza copias de seguridad a ninguno de tus archivos de audio. Asegúrate de haber realizado una copia de los mismos previamente.

¿Deseas continuar?", @@ -671,6 +688,7 @@ "MessageConfirmRenameTag": "¿Está seguro de que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?", "MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe, por lo que se fusionarán.", "MessageConfirmRenameTagWarning": "Advertencia! Una etiqueta similar ya existe \"{0}\".", + "MessageConfirmResetProgress": "¿Estás seguro de que quieres reiniciar tu progreso?", "MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?", "MessageDownloadingEpisode": "Descargando Capitulo", "MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas.", @@ -725,7 +743,6 @@ "MessageNoSeries": "Sin Series", "MessageNoTags": "Sin Etiquetas", "MessageNoTasksRunning": "Ninguna Tarea Corriendo", - "MessageNoUpdateNecessary": "No es necesario actualizar", "MessageNoUpdatesWereNecessary": "No fue necesario actualizar", "MessageNoUserPlaylists": "No tienes lista de reproducciones", "MessageNotYetImplemented": "Aun no implementado", @@ -794,7 +811,6 @@ "StatsYearInReview": "RESEÑA DEL AÑO", "ToastAccountUpdateFailed": "Error al actualizar cuenta", "ToastAccountUpdateSuccess": "Cuenta actualizada", - "ToastAuthorImageRemoveFailed": "Error al eliminar la imagen", "ToastAuthorImageRemoveSuccess": "Se eliminó la imagen del autor", "ToastAuthorUpdateFailed": "Error al actualizar el autor", "ToastAuthorUpdateMerged": "Autor combinado", @@ -811,7 +827,6 @@ "ToastBatchUpdateSuccess": "Subida masiva exitosa", "ToastBookmarkCreateFailed": "Error al crear marcador", "ToastBookmarkCreateSuccess": "Marcador Agregado", - "ToastBookmarkRemoveFailed": "Error al eliminar marcador", "ToastBookmarkRemoveSuccess": "Marcador eliminado", "ToastBookmarkUpdateFailed": "Error al actualizar el marcador", "ToastBookmarkUpdateSuccess": "Marcador actualizado", @@ -819,9 +834,7 @@ "ToastCachePurgeSuccess": "Caché purgado de manera exitosa", "ToastChaptersHaveErrors": "Los capítulos tienen errores", "ToastChaptersMustHaveTitles": "Los capítulos tienen que tener un título", - "ToastCollectionItemsRemoveFailed": "Error al remover elemento(s) de la colección", "ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección", - "ToastCollectionRemoveFailed": "Error al remover la colección", "ToastCollectionRemoveSuccess": "Colección removida", "ToastCollectionUpdateFailed": "Error al actualizar la colección", "ToastCollectionUpdateSuccess": "Colección actualizada", @@ -833,7 +846,6 @@ "ToastItemCoverUpdateSuccess": "Portada del elemento actualizada", "ToastItemDetailsUpdateFailed": "Error al actualizar los detalles del elemento", "ToastItemDetailsUpdateSuccess": "Detalles del Elemento Actualizados", - "ToastItemDetailsUpdateUnneeded": "No se necesitan actualizaciones para los detalles del Elemento", "ToastItemMarkedAsFinishedFailed": "Error al marcar como terminado", "ToastItemMarkedAsFinishedSuccess": "Elemento marcado como terminado", "ToastItemMarkedAsNotFinishedFailed": "No se ha podido marcar como no finalizado", @@ -848,7 +860,6 @@ "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada", "ToastPlaylistCreateFailed": "Error al crear la lista de reproducción", "ToastPlaylistCreateSuccess": "Lista de reproducción creada", - "ToastPlaylistRemoveFailed": "Error al eliminar la lista de reproducción", "ToastPlaylistRemoveSuccess": "Lista de reproducción eliminada", "ToastPlaylistUpdateFailed": "Error al actualizar la lista de reproducción.", "ToastPlaylistUpdateSuccess": "Lista de reproducción actualizada", diff --git a/client/strings/et.json b/client/strings/et.json index 2a1b61ed54..71c6944603 100644 --- a/client/strings/et.json +++ b/client/strings/et.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Lisa {0} raamatut kogusse", "LabelAddToPlaylist": "Lisa mänguloendisse", "LabelAddToPlaylistBatch": "Lisa {0} eset mänguloendisse", - "LabelAdded": "Lisatud", "LabelAddedAt": "Lisatud", "LabelAdminUsersOnly": "Ainult administraatorid", "LabelAll": "Kõik", @@ -692,7 +691,6 @@ "MessageNoSeries": "Ühtegi seeriat pole", "MessageNoTags": "Ühtegi silti pole", "MessageNoTasksRunning": "Ühtegi käimasolevat ülesannet pole", - "MessageNoUpdateNecessary": "Ühtegi värskendust pole vaja", "MessageNoUpdatesWereNecessary": "Ühtegi värskendust polnud vaja", "MessageNoUserPlaylists": "Teil pole ühtegi esitusloendit", "MessageNotYetImplemented": "Pole veel ellu viidud", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Otsi episoodi...", "ToastAccountUpdateFailed": "Konto värskendamine ebaõnnestus", "ToastAccountUpdateSuccess": "Konto on värskendatud", - "ToastAuthorImageRemoveFailed": "Pildi eemaldamine ebaõnnestus", "ToastAuthorImageRemoveSuccess": "Autori pilt on eemaldatud", "ToastAuthorUpdateFailed": "Autori värskendamine ebaõnnestus", "ToastAuthorUpdateMerged": "Autor liidetud", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Partii värskendamine õnnestus", "ToastBookmarkCreateFailed": "Järjehoidja loomine ebaõnnestus", "ToastBookmarkCreateSuccess": "Järjehoidja lisatud", - "ToastBookmarkRemoveFailed": "Järjehoidja eemaldamine ebaõnnestus", "ToastBookmarkRemoveSuccess": "Järjehoidja eemaldatud", "ToastBookmarkUpdateFailed": "Järjehoidja värskendamine ebaõnnestus", "ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Peatükkidel on vigu", "ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri", - "ToastCollectionItemsRemoveFailed": "Üksuse(te) eemaldamine kogumist ebaõnnestus", "ToastCollectionItemsRemoveSuccess": "Üksus(ed) eemaldatud kogumist", - "ToastCollectionRemoveFailed": "Kogumi eemaldamine ebaõnnestus", "ToastCollectionRemoveSuccess": "Kogum eemaldatud", "ToastCollectionUpdateFailed": "Kogumi värskendamine ebaõnnestus", "ToastCollectionUpdateSuccess": "Kogum värskendatud", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud", "ToastItemDetailsUpdateFailed": "Üksuse üksikasjade värskendamine ebaõnnestus", "ToastItemDetailsUpdateSuccess": "Üksuse üksikasjad värskendatud", - "ToastItemDetailsUpdateUnneeded": "Üksuse üksikasjade värskendamine pole vajalik", "ToastItemMarkedAsFinishedFailed": "Märgistamine kui lõpetatud ebaõnnestus", "ToastItemMarkedAsFinishedSuccess": "Üksus märgitud kui lõpetatud", "ToastItemMarkedAsNotFinishedFailed": "Märgistamine kui mitte lõpetatud ebaõnnestus", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Raamatukogu \"{0}\" värskendatud", "ToastPlaylistCreateFailed": "Esitusloendi loomine ebaõnnestus", "ToastPlaylistCreateSuccess": "Esitusloend loodud", - "ToastPlaylistRemoveFailed": "Esitusloendi eemaldamine ebaõnnestus", "ToastPlaylistRemoveSuccess": "Esitusloend eemaldatud", "ToastPlaylistUpdateFailed": "Esitusloendi värskendamine ebaõnnestus", "ToastPlaylistUpdateSuccess": "Esitusloend värskendatud", diff --git a/client/strings/fi.json b/client/strings/fi.json index 95e9254995..d87dfbe671 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -166,7 +166,6 @@ "LabelAddToCollectionBatch": "Lisää {0} kirjaa kokoelmaan", "LabelAddToPlaylist": "Lisää soittolistaan", "LabelAddToPlaylistBatch": "Lisää {0} kohdetta soittolistaan", - "LabelAdded": "Lisätty", "LabelAddedAt": "Lisätty listalle", "LabelAll": "Kaikki", "LabelAllUsers": "Kaikki käyttäjät", @@ -269,7 +268,6 @@ "MessageNoUserPlaylists": "Sinulla ei ole soittolistoja", "MessageReportBugsAndContribute": "Ilmoita virheistä, toivo ominaisuuksia ja osallistu", "ToastBookmarkCreateFailed": "Kirjanmerkin luominen epäonnistui", - "ToastBookmarkRemoveFailed": "Kirjanmerkin poistaminen epäonnistui", "ToastBookmarkUpdateFailed": "Kirjanmerkin päivittäminen epäonnistui", "ToastItemMarkedAsFinishedFailed": "Valmiiksi merkitseminen epäonnistui", "ToastPlaylistCreateFailed": "Soittolistan luominen epäonnistui", diff --git a/client/strings/fr.json b/client/strings/fr.json index ef9ff2b842..6c5d717268 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Sélectionner des fichiers", "ButtonClearFilter": "Effacer le filtre", "ButtonCloseFeed": "Fermer le flux", + "ButtonCloseSession": "Fermer la session", "ButtonCollections": "Collections", "ButtonConfigureScanner": "Configurer l’analyse", "ButtonCreate": "Créer", @@ -28,6 +29,9 @@ "ButtonEdit": "Modifier", "ButtonEditChapters": "Modifier les chapitres", "ButtonEditPodcast": "Modifier les podcasts", + "ButtonEnable": "Activer", + "ButtonFireAndFail": "Échec de l’action", + "ButtonFireOnTest": "Déclencher l’événement onTest", "ButtonForceReScan": "Forcer une nouvelle analyse", "ButtonFullPath": "Chemin complet", "ButtonHide": "Cacher", @@ -56,6 +60,7 @@ "ButtonPlaylists": "Listes de lecture", "ButtonPrevious": "Précédent", "ButtonPreviousChapter": "Chapitre précédent", + "ButtonProbeAudioFile": "Analyser le fichier audio", "ButtonPurgeAllCache": "Purger tout le cache", "ButtonPurgeItemsCache": "Purger le cache des éléments", "ButtonQueueAddItem": "Ajouter à la liste de lecture", @@ -93,6 +98,7 @@ "ButtonStats": "Statistiques", "ButtonSubmit": "Soumettre", "ButtonTest": "Test", + "ButtonUnlinkOpedId": "Dissocier OpenID", "ButtonUpload": "Téléverser", "ButtonUploadBackup": "Téléverser une sauvegarde", "ButtonUploadCover": "Téléverser une couverture", @@ -105,6 +111,7 @@ "ErrorUploadFetchMetadataNoResults": "Impossible de récupérer les métadonnées - essayez de mettre à jour le titre et/ou l’auteur", "ErrorUploadLacksTitle": "Doit avoir un titre", "HeaderAccount": "Compte", + "HeaderAddCustomMetadataProvider": "Ajouter un fournisseur de métadonnées personnalisé", "HeaderAdvanced": "Avancé", "HeaderAppriseNotificationSettings": "Configuration des notifications Apprise", "HeaderAudioTracks": "Pistes audio", @@ -123,8 +130,8 @@ "HeaderDetails": "Détails", "HeaderDownloadQueue": "File d’attente de téléchargements", "HeaderEbookFiles": "Fichiers des livres numériques", - "HeaderEmail": "Courriels", - "HeaderEmailSettings": "Configuration des courriels", + "HeaderEmail": "Courriel", + "HeaderEmailSettings": "Configuration de l’envoie des courriels", "HeaderEpisodes": "Épisodes", "HeaderEreaderDevices": "Lecteur de livres numériques", "HeaderEreaderSettings": "Paramètres de la liseuse", @@ -150,6 +157,8 @@ "HeaderMetadataToEmbed": "Métadonnées à intégrer", "HeaderNewAccount": "Nouveau compte", "HeaderNewLibrary": "Nouvelle bibliothèque", + "HeaderNotificationCreate": "Créer une notification", + "HeaderNotificationUpdate": "Mise à jour de la notification", "HeaderNotifications": "Notifications", "HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect", "HeaderOpenRSSFeed": "Ouvrir le flux RSS", @@ -206,8 +215,8 @@ "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToPlaylist": "Ajouter à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", - "LabelAdded": "Ajouté", "LabelAddedAt": "Date d’ajout", + "LabelAddedDate": "{0} ajoutés", "LabelAdminUsersOnly": "Administrateurs uniquement", "LabelAll": "Tout", "LabelAllUsers": "Tous les utilisateurs", @@ -289,7 +298,7 @@ "LabelEmailSettingsRejectUnauthorized": "Rejeter les certificats non autorisés", "LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « Attaque de l’homme du milieu ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.", "LabelEmailSettingsSecure": "Sécurisé", - "LabelEmailSettingsSecureHelp": "Si vous activez cette option, le protocole TLS sera utilisé lors de la connexion au serveur. Sinon, le protocole TLS sera utilisé uniquement si le serveur supporte l’extension STARTTLS. Dans la plupart des cas, activez l’option, vous vous connecterez sur le port 465. Pour le port 587 ou 25, désactivez l’option. (source : nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Adresse de test", "LabelEmbeddedCover": "Couverture du livre intégrée", "LabelEnable": "Activer", @@ -298,6 +307,7 @@ "LabelEpisode": "Épisode", "LabelEpisodeTitle": "Titre de l’épisode", "LabelEpisodeType": "Type de l’épisode", + "LabelEpisodes": "Épisodes", "LabelExample": "Exemple", "LabelExpandSeries": "Développer la série", "LabelExpandSubSeries": "Développer les sous-séries", @@ -310,6 +320,7 @@ "LabelFile": "Fichier", "LabelFileBirthtime": "Création du fichier", "LabelFileModified": "Modification du fichier", + "LabelFileModifiedDate": "{0} modifiés", "LabelFilename": "Nom de fichier", "LabelFilterByUser": "Filtrer par utilisateur", "LabelFindEpisodes": "Trouver des épisodes", @@ -448,11 +459,13 @@ "LabelPrimaryEbook": "Premier livre numérique", "LabelProgress": "Progression", "LabelProvider": "Fournisseur", + "LabelProviderAuthorizationValue": "Valeur de l’en-tête d’autorisation", "LabelPubDate": "Date de publication", "LabelPublishYear": "Année de publication", + "LabelPublishedDate": "{0} publiés", "LabelPublisher": "Éditeur", "LabelPublishers": "Éditeurs", - "LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé", + "LabelRSSFeedCustomOwnerEmail": "Courriel personnalisée du propriétaire", "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", "LabelRSSFeedOpen": "Flux RSS ouvert", "LabelRSSFeedPreventIndexing": "Empêcher l’indexation", @@ -595,6 +608,7 @@ "LabelUnabridged": "Version intégrale", "LabelUndo": "Annuler", "LabelUnknown": "Inconnu", + "LabelUnknownPublishDate": "Date de publication inconnue", "LabelUpdateCover": "Mettre à jour la couverture", "LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsqu’une correspondance est trouvée", "LabelUpdateDetails": "Mettre à jours les détails", @@ -643,16 +657,22 @@ "MessageCheckingCron": "Vérification du cron…", "MessageConfirmCloseFeed": "Êtes-vous sûr de vouloir fermer ce flux ?", "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?", + "MessageConfirmDeleteDevice": "Êtes-vous sûr de vouloir supprimer la liseuse « {0} » ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", "MessageConfirmDeleteLibraryItem": "Cette opération supprimera l’élément de la base de données et de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteLibraryItems": "Cette opération supprimera {0} éléments de la base de données et de votre système de fichiers. Êtes-vous sûr ?", + "MessageConfirmDeleteMetadataProvider": "Êtes-vous sûr de vouloir supprimer le fournisseur de métadonnées personnalisées « {0} » ?", + "MessageConfirmDeleteNotification": "Êtes-vous sûr de vouloir supprimer cette notification ?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?", + "MessageConfirmMarkItemFinished": "Êtes-vous sûr de vouloir marquer \"{0}\" comme terminé ?", + "MessageConfirmMarkItemNotFinished": "Êtes-vous sûr de vouloir marquer \"{0}\" comme non terminé ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme non terminés ?", + "MessageConfirmNotificationTestTrigger": "Déclencher cette notification avec des données de test ?", "MessageConfirmPurgeCache": "La purge du cache supprimera l’intégralité du répertoire à /metadata/cache.

Êtes-vous sûr de vouloir supprimer le répertoire de cache ?", "MessageConfirmPurgeItemsCache": "Purger le cache des éléments supprimera l'ensemble du répertoire /metadata/cache/items.
Êtes-vous sûr ?", "MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

Souhaitez-vous continuer ?", @@ -671,13 +691,15 @@ "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les éléments ?", "MessageConfirmRenameTagMergeNote": "Information : Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", + "MessageConfirmResetProgress": "Êtes-vous sûr de vouloir réinitialiser votre progression ?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer {0} livre numérique « {1} » à l'appareil « {2} » ?", + "MessageConfirmUnlinkOpenId": "Êtes-vous sûr de vouloir dissocier cet utilisateur d’OpenID ?", "MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans l’ordre correct des pistes", "MessageEmbedFailed": "Échec de l’intégration !", "MessageEmbedFinished": "Intégration terminée !", "MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement", - "MessageEreaderDevices": "Pour garantir l’envoi des livres électroniques, il se peut que vous deviez ajouter l’adresse électronique ci-dessus en tant qu’expéditeur valide pour chaque appareil répertorié ci-dessous.", + "MessageEreaderDevices": "Pour garantir la livraison des livres électroniques, vous devrez peut-être ajouter le courriel ci-dessus comme expéditeur valide pour chaque appareil répertorié ci-dessous.", "MessageFeedURLWillBe": "L’URL du flux sera {0}", "MessageFetching": "Récupération…", "MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme s’ils étaient nouveaux.", @@ -706,6 +728,7 @@ "MessageNoCollections": "Aucune collection", "MessageNoCoversFound": "Aucune couverture trouvée", "MessageNoDescription": "Aucune description", + "MessageNoDevices": "Aucun appareil", "MessageNoDownloadsInProgress": "Aucun téléchargement en cours", "MessageNoDownloadsQueued": "Aucun téléchargement en attente", "MessageNoEpisodeMatchesFound": "Aucune correspondance d’épisode trouvée", @@ -725,7 +748,6 @@ "MessageNoSeries": "Aucune série", "MessageNoTags": "Aucune étiquette", "MessageNoTasksRunning": "Aucune tâche en cours", - "MessageNoUpdateNecessary": "Aucune mise à jour nécessaire", "MessageNoUpdatesWereNecessary": "Aucune mise à jour n’était nécessaire", "MessageNoUserPlaylists": "Vous n’avez aucune liste de lecture", "MessageNotYetImplemented": "Non implémenté", @@ -734,6 +756,7 @@ "MessagePauseChapter": "Suspendre la lecture du chapitre", "MessagePlayChapter": "Écouter depuis le début du chapitre", "MessagePlaylistCreateFromCollection": "Créer une liste de lecture depuis la collection", + "MessagePleaseWait": "Merci de patienter…", "MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n’a pas d’URL de flux RSS à utiliser pour la correspondance", "MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». N’écrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.", "MessageRemoveChapter": "Supprimer le chapitre", @@ -782,7 +805,7 @@ "StatsBooksListenedTo": "livres écoutés", "StatsCollectionGrewTo": "Votre collection de livres a atteint…", "StatsSessions": "sessions", - "StatsSpentListening": "temps passé à l’écoute", + "StatsSpentListening": "temps passé à écouter", "StatsTopAuthor": "TOP AUTEUR", "StatsTopAuthors": "TOP AUTEURS", "StatsTopGenre": "TOP GENRE", @@ -794,24 +817,32 @@ "StatsYearInReview": "BILAN DE L’ANNÉE", "ToastAccountUpdateFailed": "Échec de la mise à jour du compte", "ToastAccountUpdateSuccess": "Compte mis à jour", - "ToastAuthorImageRemoveFailed": "Échec de la suppression de l’image", + "ToastAppriseUrlRequired": "Vous devez entrer une URL Apprise", "ToastAuthorImageRemoveSuccess": "Image de l’auteur supprimée", + "ToastAuthorNotFound": "Auteur \"{0}\" non trouvé", + "ToastAuthorRemoveSuccess": "Auteur supprimé", + "ToastAuthorSearchNotFound": "Auteur non trouvé", "ToastAuthorUpdateFailed": "Échec de la mise à jour de l’auteur", "ToastAuthorUpdateMerged": "Auteur fusionné", "ToastAuthorUpdateSuccess": "Auteur mis à jour", "ToastAuthorUpdateSuccessNoImageFound": "Auteur mis à jour (aucune image trouvée)", + "ToastBackupAppliedSuccess": "Sauvegarde appliquée", "ToastBackupCreateFailed": "Échec de la création de sauvegarde", "ToastBackupCreateSuccess": "Sauvegarde créée", "ToastBackupDeleteFailed": "Échec de la suppression de sauvegarde", "ToastBackupDeleteSuccess": "Sauvegarde supprimée", + "ToastBackupInvalidMaxKeep": "Nombre de sauvegardes à conserver invalide", + "ToastBackupInvalidMaxSize": "Taille maximale de sauvegarde invalide", + "ToastBackupPathUpdateFailed": "Échec de la mise à jour du chemin de sauvegarde", "ToastBackupRestoreFailed": "Échec de la restauration de sauvegarde", "ToastBackupUploadFailed": "Échec du téléversement de sauvegarde", "ToastBackupUploadSuccess": "Sauvegarde téléversée", + "ToastBatchDeleteFailed": "Échec de la suppression par lot", + "ToastBatchDeleteSuccess": "Suppression par lot réussie", "ToastBatchUpdateFailed": "Échec de la mise à jour par lot", "ToastBatchUpdateSuccess": "Mise à jour par lot terminée", "ToastBookmarkCreateFailed": "Échec de la création de signet", "ToastBookmarkCreateSuccess": "Signet ajouté", - "ToastBookmarkRemoveFailed": "Échec de la suppression de signet", "ToastBookmarkRemoveSuccess": "Signet supprimé", "ToastBookmarkUpdateFailed": "Échec de la mise à jour de signet", "ToastBookmarkUpdateSuccess": "Signet mis à jour", @@ -819,21 +850,27 @@ "ToastCachePurgeSuccess": "Cache purgé avec succès", "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", "ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre", - "ToastCollectionItemsRemoveFailed": "Échec de la suppression d’un ou plusieurs éléments de la collection", + "ToastChaptersRemoved": "Chapitres supprimés", + "ToastCollectionItemsAddFailed": "Échec de l’ajout de(s) élément(s) à la collection", + "ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi", "ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection", - "ToastCollectionRemoveFailed": "Échec de la suppression de la collection", "ToastCollectionRemoveSuccess": "Collection supprimée", "ToastCollectionUpdateFailed": "Échec de la mise à jour de la collection", "ToastCollectionUpdateSuccess": "Collection mise à jour", + "ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture", "ToastDeleteFileFailed": "Échec de la suppression du fichier", "ToastDeleteFileSuccess": "Fichier supprimé", + "ToastDeviceAddFailed": "Échec de l’ajout de l’appareil", + "ToastDeviceNameAlreadyExists": "Un appareil de lecture avec ce nom existe déjà", + "ToastDeviceTestEmailFailed": "Échec de l’envoi du courriel de test", + "ToastDeviceTestEmailSuccess": "Courriel de test envoyé", + "ToastEmailSettingsUpdateFailed": "Échec de la mise à jour des paramètres de messagerie", "ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil", "ToastFailedToLoadData": "Échec du chargement des données", "ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de l’élément", "ToastItemCoverUpdateSuccess": "Couverture mise à jour", "ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de l’élément", "ToastItemDetailsUpdateSuccess": "Détails de l’élément mis à jour", - "ToastItemDetailsUpdateUnneeded": "Aucune mise à jour n’est nécessaire pour les détails de l’élément", "ToastItemMarkedAsFinishedFailed": "Échec de l’annotation terminée", "ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé", "ToastItemMarkedAsNotFinishedFailed": "Échec de l’annotation non-terminée", @@ -848,7 +885,6 @@ "ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour", "ToastPlaylistCreateFailed": "Échec de la création de la liste de lecture", "ToastPlaylistCreateSuccess": "Liste de lecture créée", - "ToastPlaylistRemoveFailed": "Échec de la suppression de la liste de lecture", "ToastPlaylistRemoveSuccess": "Liste de lecture supprimée", "ToastPlaylistUpdateFailed": "Échec de la mise à jour de la liste de lecture", "ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour", diff --git a/client/strings/gu.json b/client/strings/gu.json index c38a005f7c..d2b0bc194c 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", - "LabelAdded": "Added", "LabelAddedAt": "Added At", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", @@ -692,7 +691,6 @@ "MessageNoSeries": "No Series", "MessageNoTags": "No Tags", "MessageNoTasksRunning": "No Tasks Running", - "MessageNoUpdateNecessary": "No update necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUserPlaylists": "You have no playlists", "MessageNotYetImplemented": "Not yet implemented", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Search episode..", "ToastAccountUpdateFailed": "Failed to update account", "ToastAccountUpdateSuccess": "Account updated", - "ToastAuthorImageRemoveFailed": "Failed to remove image", "ToastAuthorImageRemoveSuccess": "Author image removed", "ToastAuthorUpdateFailed": "Failed to update author", "ToastAuthorUpdateMerged": "Author merged", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Batch update success", "ToastBookmarkCreateFailed": "Failed to create bookmark", "ToastBookmarkCreateSuccess": "Bookmark added", - "ToastBookmarkRemoveFailed": "Failed to remove bookmark", "ToastBookmarkRemoveSuccess": "Bookmark removed", "ToastBookmarkUpdateFailed": "Failed to update bookmark", "ToastBookmarkUpdateSuccess": "Bookmark updated", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersMustHaveTitles": "Chapters must have titles", - "ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection", "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection", - "ToastCollectionRemoveFailed": "Failed to remove collection", "ToastCollectionRemoveSuccess": "Collection removed", "ToastCollectionUpdateFailed": "Failed to update collection", "ToastCollectionUpdateSuccess": "Collection updated", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Item cover updated", "ToastItemDetailsUpdateFailed": "Failed to update item details", "ToastItemDetailsUpdateSuccess": "Item details updated", - "ToastItemDetailsUpdateUnneeded": "No updates needed for item details", "ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished", "ToastItemMarkedAsFinishedSuccess": "Item marked as Finished", "ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Library \"{0}\" updated", "ToastPlaylistCreateFailed": "Failed to create playlist", "ToastPlaylistCreateSuccess": "Playlist created", - "ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateSuccess": "Playlist updated", diff --git a/client/strings/he.json b/client/strings/he.json index 514639405d..4d701d9ef2 100644 --- a/client/strings/he.json +++ b/client/strings/he.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "הוסף {0} ספרים לאוסף", "LabelAddToPlaylist": "הוסף לרשימת השמעה", "LabelAddToPlaylistBatch": "הוסף {0} פריטים לרשימת השמעה", - "LabelAdded": "נוסף", "LabelAddedAt": "נוסף בתאריך", "LabelAdminUsersOnly": "רק מנהלים", "LabelAll": "הכל", @@ -692,7 +691,6 @@ "MessageNoSeries": "אין סדרות", "MessageNoTags": "אין תגיות", "MessageNoTasksRunning": "אין משימות פעילות", - "MessageNoUpdateNecessary": "לא נדרש עדכון", "MessageNoUpdatesWereNecessary": "לא היה צורך בעדכונים", "MessageNoUserPlaylists": "אין לך רשימות השמעה", "MessageNotYetImplemented": "עדיין לא מיושם", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "חיפוש פרק..", "ToastAccountUpdateFailed": "עדכון חשבון נכשל", "ToastAccountUpdateSuccess": "חשבון עודכן בהצלחה", - "ToastAuthorImageRemoveFailed": "הסרת התמונה של המחבר נכשלה", "ToastAuthorImageRemoveSuccess": "תמונת המחבר הוסרה בהצלחה", "ToastAuthorUpdateFailed": "עדכון המחבר נכשל", "ToastAuthorUpdateMerged": "המחבר מוזג", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "עדכון קבוצתי הצליח", "ToastBookmarkCreateFailed": "יצירת סימניה נכשלה", "ToastBookmarkCreateSuccess": "הסימניה נוספה בהצלחה", - "ToastBookmarkRemoveFailed": "הסרת הסימניה נכשלה", "ToastBookmarkRemoveSuccess": "הסימניה הוסרה בהצלחה", "ToastBookmarkUpdateFailed": "עדכון הסימניה נכשל", "ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "פרקים מכילים שגיאות", "ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות", - "ToastCollectionItemsRemoveFailed": "הסרת הפריט(ים) מהאוסף נכשלה", "ToastCollectionItemsRemoveSuccess": "הפריט(ים) הוסרו מהאוסף בהצלחה", - "ToastCollectionRemoveFailed": "מחיקת האוסף נכשלה", "ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה", "ToastCollectionUpdateFailed": "עדכון האוסף נכשל", "ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה", "ToastItemDetailsUpdateFailed": "עדכון פרטי הפריט נכשל", "ToastItemDetailsUpdateSuccess": "פרטי הפריט עודכנו בהצלחה", - "ToastItemDetailsUpdateUnneeded": "לא נדרשים עדכונים לפרטי הפריט", "ToastItemMarkedAsFinishedFailed": "סימון כפריט כהושלם נכשל", "ToastItemMarkedAsFinishedSuccess": "הפריט סומן כהושלם בהצלחה", "ToastItemMarkedAsNotFinishedFailed": "סימון כפריט שלא הושלם נכשל", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "הספרייה \"{0}\" עודכנה בהצלחה", "ToastPlaylistCreateFailed": "יצירת רשימת השמעה נכשלה", "ToastPlaylistCreateSuccess": "רשימת השמעה נוצרה בהצלחה", - "ToastPlaylistRemoveFailed": "הסרת רשימת השמעה נכשלה", "ToastPlaylistRemoveSuccess": "רשימת השמעה הוסרה בהצלחה", "ToastPlaylistUpdateFailed": "עדכון רשימת השמעה נכשל", "ToastPlaylistUpdateSuccess": "רשימת השמעה עודכנה בהצלחה", diff --git a/client/strings/hi.json b/client/strings/hi.json index f2112979f9..53ee04a4a7 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", - "LabelAdded": "Added", "LabelAddedAt": "Added At", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", @@ -692,7 +691,6 @@ "MessageNoSeries": "No Series", "MessageNoTags": "No Tags", "MessageNoTasksRunning": "No Tasks Running", - "MessageNoUpdateNecessary": "No update necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUserPlaylists": "You have no playlists", "MessageNotYetImplemented": "Not yet implemented", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Search episode..", "ToastAccountUpdateFailed": "Failed to update account", "ToastAccountUpdateSuccess": "Account updated", - "ToastAuthorImageRemoveFailed": "Failed to remove image", "ToastAuthorImageRemoveSuccess": "Author image removed", "ToastAuthorUpdateFailed": "Failed to update author", "ToastAuthorUpdateMerged": "Author merged", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Batch update success", "ToastBookmarkCreateFailed": "Failed to create bookmark", "ToastBookmarkCreateSuccess": "Bookmark added", - "ToastBookmarkRemoveFailed": "Failed to remove bookmark", "ToastBookmarkRemoveSuccess": "Bookmark removed", "ToastBookmarkUpdateFailed": "Failed to update bookmark", "ToastBookmarkUpdateSuccess": "Bookmark updated", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersMustHaveTitles": "Chapters must have titles", - "ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection", "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection", - "ToastCollectionRemoveFailed": "Failed to remove collection", "ToastCollectionRemoveSuccess": "Collection removed", "ToastCollectionUpdateFailed": "Failed to update collection", "ToastCollectionUpdateSuccess": "Collection updated", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Item cover updated", "ToastItemDetailsUpdateFailed": "Failed to update item details", "ToastItemDetailsUpdateSuccess": "Item details updated", - "ToastItemDetailsUpdateUnneeded": "No updates needed for item details", "ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished", "ToastItemMarkedAsFinishedSuccess": "Item marked as Finished", "ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Library \"{0}\" updated", "ToastPlaylistCreateFailed": "Failed to create playlist", "ToastPlaylistCreateSuccess": "Playlist created", - "ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateSuccess": "Playlist updated", diff --git a/client/strings/hr.json b/client/strings/hr.json index ea52e6af9a..b9b1c00cc4 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -2,10 +2,10 @@ "ButtonAdd": "Dodaj", "ButtonAddChapters": "Dodaj poglavlja", "ButtonAddDevice": "Dodaj uređaj", - "ButtonAddLibrary": "Dodaj biblioteku", - "ButtonAddPodcasts": "Dodaj podkaste", + "ButtonAddLibrary": "Dodaj knjižnicu", + "ButtonAddPodcasts": "Dodaj podcaste", "ButtonAddUser": "Dodaj korisnika", - "ButtonAddYourFirstLibrary": "Dodaj svoju prvu biblioteku", + "ButtonAddYourFirstLibrary": "Dodaj svoju prvu knjižnicu", "ButtonApply": "Primijeni", "ButtonApplyChapters": "Primijeni poglavlja", "ButtonAuthors": "Autori", @@ -13,21 +13,25 @@ "ButtonBrowseForFolder": "Pronađi mapu", "ButtonCancel": "Odustani", "ButtonCancelEncode": "Otkaži kodiranje", - "ButtonChangeRootPassword": "Promijeni Root lozinku", + "ButtonChangeRootPassword": "Promijeni zaporku root korisnika", "ButtonCheckAndDownloadNewEpisodes": "Provjeri i preuzmi nove epizode", - "ButtonChooseAFolder": "Odaberi folder", + "ButtonChooseAFolder": "Odaberi mapu", "ButtonChooseFiles": "Odaberi datoteke", "ButtonClearFilter": "Poništi filter", - "ButtonCloseFeed": "Zatvori feed", + "ButtonCloseFeed": "Zatvori izvor", + "ButtonCloseSession": "Zatvori otvorenu sesiju", "ButtonCollections": "Zbirke", "ButtonConfigureScanner": "Postavi skener", "ButtonCreate": "Izradi", - "ButtonCreateBackup": "Napravi backup", + "ButtonCreateBackup": "Izradi sigurnosnu kopiju", "ButtonDelete": "Izbriši", - "ButtonDownloadQueue": "Redoslijed izvođenja", + "ButtonDownloadQueue": "Red", "ButtonEdit": "Uredi", "ButtonEditChapters": "Uredi poglavlja", "ButtonEditPodcast": "Uredi podcast", + "ButtonEnable": "Omogući", + "ButtonFireAndFail": "Okini i vrati status neuspješno", + "ButtonFireOnTest": "Okini onTest događaj", "ButtonForceReScan": "Prisilno ponovno skeniranje", "ButtonFullPath": "Cijela putanja", "ButtonHide": "Sakrij", @@ -39,29 +43,30 @@ "ButtonLibrary": "Knjižnica", "ButtonLogout": "Odjavi se", "ButtonLookup": "Potraži", - "ButtonManageTracks": "Upravljanje pjesmama", - "ButtonMapChapterTitles": "Mapiraj imena poglavlja", - "ButtonMatchAllAuthors": "Matchaj sve autore", - "ButtonMatchBooks": "Matchaj knjige", + "ButtonManageTracks": "Upravljanje zvučnim zapisima", + "ButtonMapChapterTitles": "Mapiraj naslove poglavlja", + "ButtonMatchAllAuthors": "Prepoznaj sve autore", + "ButtonMatchBooks": "Prepoznaj knjige", "ButtonNevermind": "Nije bitno", - "ButtonNext": "Next", + "ButtonNext": "Sljedeće", "ButtonNextChapter": "Sljedeće poglavlje", - "ButtonNextItemInQueue": "Sljedeća stavka u redoslijedu izvođenja", - "ButtonOk": "Ok", + "ButtonNextItemInQueue": "Sljedeća stavka u redu", + "ButtonOk": "OK", "ButtonOpenFeed": "Otvori izvor", - "ButtonOpenManager": "Otvori menadžera", + "ButtonOpenManager": "Otvori Upravitelja", "ButtonPause": "Pauziraj", - "ButtonPlay": "Pokreni", + "ButtonPlay": "Reproduciraj", "ButtonPlaying": "Izvodi se", "ButtonPlaylists": "Popisi za izvođenje", "ButtonPrevious": "Prethodno", "ButtonPreviousChapter": "Prethodno poglavlje", - "ButtonPurgeAllCache": "Isprazni sav cache", - "ButtonPurgeItemsCache": "Isprazni Items Cache", - "ButtonQueueAddItem": "Dodaj na redoslijed izvođenja", - "ButtonQueueRemoveItem": "Ukloni s redoslijeda izvođenja", + "ButtonProbeAudioFile": "Ispitaj zvučnu datoteku", + "ButtonPurgeAllCache": "Isprazni cijelu predmemoriju", + "ButtonPurgeItemsCache": "Isprazni predmemoriju stavki", + "ButtonQueueAddItem": "Dodaj u red", + "ButtonQueueRemoveItem": "Ukloni iz reda", "ButtonQuickEmbedMetadata": "Brzo ugrađivanje meta-podataka", - "ButtonQuickMatch": "Brzi match", + "ButtonQuickMatch": "Brzo prepoznavanje", "ButtonReScan": "Skeniraj ponovno", "ButtonRead": "Pročitaj", "ButtonReadLess": "Pročitaj manje", @@ -69,61 +74,63 @@ "ButtonRefresh": "Osvježi", "ButtonRemove": "Ukloni", "ButtonRemoveAll": "Ukloni sve", - "ButtonRemoveAllLibraryItems": "Ukloni sve stvari iz biblioteke", + "ButtonRemoveAllLibraryItems": "Ukloni sve stavke iz knjižnice", "ButtonRemoveFromContinueListening": "Ukloni iz Nastavi slušati", - "ButtonRemoveFromContinueReading": "Ukloni s popisa Nastavi", + "ButtonRemoveFromContinueReading": "Ukloni iz Nastavi čitati", "ButtonRemoveSeriesFromContinueSeries": "Ukloni seriju iz Nastavi seriju", "ButtonReset": "Poništi", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "Vrati na početne postavke", "ButtonRestore": "Povrati", "ButtonSave": "Spremi", "ButtonSaveAndClose": "Spremi i zatvori", - "ButtonSaveTracklist": "Spremi popis pjesama", + "ButtonSaveTracklist": "Spremi popis zvučnih zapisa", "ButtonScan": "Skeniraj", - "ButtonScanLibrary": "Skeniraj biblioteku", + "ButtonScanLibrary": "Skeniraj knjižnicu", "ButtonSearch": "Traži", - "ButtonSelectFolderPath": "Odaberi putanju do folder", + "ButtonSelectFolderPath": "Odaberi putanju mape", "ButtonSeries": "Serijali", - "ButtonSetChaptersFromTracks": "Postavi poglavlja iz datoteka", + "ButtonSetChaptersFromTracks": "Postavi poglavlja iz zvučnih zapisa", "ButtonShare": "Podijeli", "ButtonShiftTimes": "Pomakni vremena", "ButtonShow": "Prikaži", "ButtonStartM4BEncode": "Pokreni M4B kodiranje", - "ButtonStartMetadataEmbed": "Pokreni ugradnju metapodataka", + "ButtonStartMetadataEmbed": "Pokreni ugradnju meta-podataka", "ButtonStats": "Statistika", "ButtonSubmit": "Podnesi", "ButtonTest": "Test", - "ButtonUpload": "Upload", - "ButtonUploadBackup": "Upload backup", + "ButtonUnlinkOpedId": "Odspoji OpenID", + "ButtonUpload": "Učitaj", + "ButtonUploadBackup": "Učitaj sigurnosnu kopiju", "ButtonUploadCover": "Učitaj naslovnicu", - "ButtonUploadOPMLFile": "Upload OPML Datoteku", - "ButtonUserDelete": "Delete user {0}", + "ButtonUploadOPMLFile": "Učitaj OPML datoteku", + "ButtonUserDelete": "Izbriši korisnika {0}", "ButtonUserEdit": "Uredi korisnika {0}", "ButtonViewAll": "Prikaži sve", "ButtonYes": "Da", - "ErrorUploadFetchMetadataAPI": "Pogreška pri dohvatu metapodataka", - "ErrorUploadFetchMetadataNoResults": "Dohvat metapodataka nije uspio - pokušajte ispraviti naslov i/ili autora", + "ErrorUploadFetchMetadataAPI": "Pogreška pri dohvatu meta-podataka", + "ErrorUploadFetchMetadataNoResults": "Dohvat meta-podataka nije uspio - pokušajte ispraviti naslov i/ili autora", "ErrorUploadLacksTitle": "Naslov je obavezan", "HeaderAccount": "Korisnički račun", + "HeaderAddCustomMetadataProvider": "Dodaj prilagođenog pružatelja meta-podataka", "HeaderAdvanced": "Napredno", "HeaderAppriseNotificationSettings": "Postavke obavijesti Apprise", "HeaderAudioTracks": "Zvučni zapisi", - "HeaderAudiobookTools": "Audiobook File Management alati", - "HeaderAuthentication": "Authentication", + "HeaderAudiobookTools": "Alati za upravljanje datotekama zvučnih knjiga", + "HeaderAuthentication": "Provjera autentičnosti", "HeaderBackups": "Sigurnosne kopije", - "HeaderChangePassword": "Promijeni lozinku", + "HeaderChangePassword": "Promijeni zaporku", "HeaderChapters": "Poglavlja", - "HeaderChooseAFolder": "Odaberi folder", + "HeaderChooseAFolder": "Odaberi mapu", "HeaderCollection": "Zbirka", "HeaderCollectionItems": "Stavke u zbirci", "HeaderCover": "Naslovnica", "HeaderCurrentDownloads": "Preuzimanja u tijeku", "HeaderCustomMessageOnLogin": "Prilagođena poruka prilikom prijave", - "HeaderCustomMetadataProviders": "Prilagođeni pružatelji metapodataka", + "HeaderCustomMetadataProviders": "Prilagođeni pružatelji meta-podataka", "HeaderDetails": "Pojedinosti", - "HeaderDownloadQueue": "Redoslijed preuzimanja", + "HeaderDownloadQueue": "Red preuzimanja", "HeaderEbookFiles": "Datoteke e-knjiga", - "HeaderEmail": "Email", + "HeaderEmail": "E-pošta", "HeaderEmailSettings": "Postavke e-pošte", "HeaderEpisodes": "Nastavci", "HeaderEreaderDevices": "E-čitači", @@ -131,53 +138,56 @@ "HeaderFiles": "Datoteke", "HeaderFindChapters": "Pronađi poglavlja", "HeaderIgnoredFiles": "Zanemarene datoteke", - "HeaderItemFiles": "Item Files", - "HeaderItemMetadataUtils": "Alati za metapodatke", - "HeaderLastListeningSession": "Posljednja Listening Session", + "HeaderItemFiles": "Datoteke stavke", + "HeaderItemMetadataUtils": "Alati za meta-podatke", + "HeaderLastListeningSession": "Posljednja sesija slušanja", "HeaderLatestEpisodes": "Najnoviji nastavci", "HeaderLibraries": "Knjižnice", - "HeaderLibraryFiles": "Library Files", - "HeaderLibraryStats": "Library Stats", - "HeaderListeningSessions": "Listening Sessions", - "HeaderListeningStats": "Listening Stats", - "HeaderLogin": "Prijavljivanje", - "HeaderLogs": "Logs", - "HeaderManageGenres": "Manage Genres", - "HeaderManageTags": "Manage Tags", - "HeaderMapDetails": "Map details", - "HeaderMatch": "Match", - "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", - "HeaderMetadataToEmbed": "Metapodatci za ugradnju", + "HeaderLibraryFiles": "Datoteke knjižnice", + "HeaderLibraryStats": "Statistika knjižnice", + "HeaderListeningSessions": "Sesije slušanja", + "HeaderListeningStats": "Statistika slušanja", + "HeaderLogin": "Prijava", + "HeaderLogs": "Zapisnici", + "HeaderManageGenres": "Upravljanje žanrovima", + "HeaderManageTags": "Upravljanje oznakama", + "HeaderMapDetails": "Mapiranje pojedinosti", + "HeaderMatch": "Prepoznavanje", + "HeaderMetadataOrderOfPrecedence": "Redoslijed prihvaćanja meta-podataka", + "HeaderMetadataToEmbed": "Meta-podatci za ugradnju", "HeaderNewAccount": "Novi korisnički račun", - "HeaderNewLibrary": "Nova biblioteka", + "HeaderNewLibrary": "Nova knjižnica", + "HeaderNotificationCreate": "Izradi obavijest", + "HeaderNotificationUpdate": "Ažuriraj obavijest", "HeaderNotifications": "Obavijesti", - "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", + "HeaderOpenIDConnectAuthentication": "Prijava na OpenID Connect", "HeaderOpenRSSFeed": "Otvori RSS izvor", "HeaderOtherFiles": "Druge datoteke", - "HeaderPasswordAuthentication": "Password Authentication", + "HeaderPasswordAuthentication": "Provjera autentičnosti zaporkom", "HeaderPermissions": "Dozvole", - "HeaderPlayerQueue": "Player Queue", + "HeaderPlayerQueue": "Redoslijed izvođenja", + "HeaderPlayerSettings": "Postavke reproduktora", "HeaderPlaylist": "Popis za izvođenje", "HeaderPlaylistItems": "Stavke popisa za izvođenje", - "HeaderPodcastsToAdd": "Podcasti za dodati", - "HeaderPreviewCover": "Pregledaj Cover", + "HeaderPodcastsToAdd": "Podcasti za dodavanje", + "HeaderPreviewCover": "Pretpregled naslovnice", "HeaderRSSFeedGeneral": "RSS pojedinosti", "HeaderRSSFeedIsOpen": "RSS izvor je otvoren", - "HeaderRSSFeeds": "RSS Feeds", - "HeaderRemoveEpisode": "Ukloni epizodu", - "HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e", - "HeaderSavedMediaProgress": "Spremljen Media Progress", - "HeaderSchedule": "Schedule", - "HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke", + "HeaderRSSFeeds": "RSS izvori", + "HeaderRemoveEpisode": "Ukloni nastavak", + "HeaderRemoveEpisodes": "Ukloni {0} nastavaka", + "HeaderSavedMediaProgress": "Spremljen napredak medija", + "HeaderSchedule": "Zakazivanje", + "HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje knjižnice", "HeaderSession": "Sesija", - "HeaderSetBackupSchedule": "Set Backup Schedule", + "HeaderSetBackupSchedule": "Zakazivanje sigurnosne pohrane", "HeaderSettings": "Postavke", - "HeaderSettingsDisplay": "Zaslon", - "HeaderSettingsExperimental": "Eksperimentalni Features", - "HeaderSettingsGeneral": "Opčenito", - "HeaderSettingsScanner": "Scanner", + "HeaderSettingsDisplay": "Prikaz", + "HeaderSettingsExperimental": "Eksperimentalne značajke", + "HeaderSettingsGeneral": "Općenito", + "HeaderSettingsScanner": "Skener", "HeaderSleepTimer": "Timer za spavanje", - "HeaderStatsLargestItems": "Largest Items", + "HeaderStatsLargestItems": "Najveće stavke", "HeaderStatsLongestItems": "Najduže stavke (sati)", "HeaderStatsMinutesListeningChart": "Odslušanih minuta (posljednjih 7 dana)", "HeaderStatsRecentSessions": "Nedavne sesije", @@ -185,201 +195,214 @@ "HeaderStatsTop5Genres": "Top 5 žanrova", "HeaderTableOfContents": "Sadržaj", "HeaderTools": "Alati", - "HeaderUpdateAccount": "Aktualiziraj Korisnički račun", - "HeaderUpdateAuthor": "Aktualiziraj autora", - "HeaderUpdateDetails": "Aktualiziraj detalje", - "HeaderUpdateLibrary": "Aktualiziraj biblioteku", - "HeaderUsers": "Korinici", - "HeaderYearReview": "Year {0} in Review", + "HeaderUpdateAccount": "Ažuriraj korisnički račun", + "HeaderUpdateAuthor": "Ažuriraj autora", + "HeaderUpdateDetails": "Ažuriraj pojedinosti", + "HeaderUpdateLibrary": "Ažuriraj knjižnicu", + "HeaderUsers": "Korisnici", + "HeaderYearReview": "Pregled godine {0}", "HeaderYourStats": "Vaša statistika", - "LabelAbridged": "Abridged", - "LabelAbridgedChecked": "Abridged (checked)", - "LabelAbridgedUnchecked": "Unabridged (unchecked)", - "LabelAccessibleBy": "Accessible by", + "LabelAbridged": "Skraćena", + "LabelAbridgedChecked": "Skraćena (označeno)", + "LabelAbridgedUnchecked": "Neskraćena (neoznačeno)", + "LabelAccessibleBy": "Dostupno", "LabelAccountType": "Vrsta korisničkog računa", "LabelAccountTypeAdmin": "Administrator", "LabelAccountTypeGuest": "Gost", "LabelAccountTypeUser": "Korisnik", "LabelActivity": "Aktivnost", - "LabelAddToCollection": "Dodaj u kolekciju", - "LabelAddToCollectionBatch": "Add {0} Books to Collection", + "LabelAddToCollection": "Dodaj u zbirku", + "LabelAddToCollectionBatch": "Dodaj {0} knjiga u zbirku", "LabelAddToPlaylist": "Dodaj na popis za izvođenje", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", - "LabelAdded": "Dodano", "LabelAddedAt": "Dodano u", - "LabelAdminUsersOnly": "Admin users only", + "LabelAddedDate": "Dodano {0}", + "LabelAdminUsersOnly": "Samo korisnici administratori", "LabelAll": "Sve", "LabelAllUsers": "Svi korisnici", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", - "LabelAlreadyInYourLibrary": "Already in your library", - "LabelAppend": "Append", + "LabelAllUsersExcludingGuests": "Svi korisnici osim gostiju", + "LabelAllUsersIncludingGuests": "Svi korisnici uključujući i goste", + "LabelAlreadyInYourLibrary": "Već u vašoj knjižnici", + "LabelAppend": "Pridodaj", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (Ime Prezime)", "LabelAuthorLastFirst": "Autor (Prezime, Ime)", "LabelAuthors": "Autori", "LabelAutoDownloadEpisodes": "Automatski preuzmi nastavke", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", - "LabelAutoLaunch": "Auto Launch", - "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path /login?autoLaunch=0)", - "LabelAutoRegister": "Auto Register", - "LabelAutoRegisterDescription": "Automatically create new users after logging in", - "LabelBackToUser": "Nazad k korisniku", - "LabelBackupLocation": "Backup Location", - "LabelBackupsEnableAutomaticBackups": "Uključi automatski backup", - "LabelBackupsEnableAutomaticBackupsHelp": "Backups spremljeni u /metadata/backups", - "LabelBackupsMaxBackupSize": "Maksimalna količina backupa (u GB)", - "LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.", - "LabelBackupsNumberToKeep": "Broj backupa zadržati", - "LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.", - "LabelBitrate": "Bitrate", + "LabelAutoFetchMetadata": "Automatski dohvati meta-podatke", + "LabelAutoFetchMetadataHelp": "Dohvaća meta-podatke o naslovu, autoru i serijalu kako bi pojednostavnio učitavanje. Dodatni meta-podatci će se možda morati dohvatiti nakon učitavanja.", + "LabelAutoLaunch": "Automatsko pokretanje", + "LabelAutoLaunchDescription": "Automatski preusmjeri na pružatelja autentifikacijskih usluga prilikom otvaranja stranice za prijavu (putanja za ručno zaobilaženje opcije /login?autoLaunch=0)", + "LabelAutoRegister": "Automatska registracija", + "LabelAutoRegisterDescription": "Automatski izradi nove korisnike nakon prijave", + "LabelBackToUser": "Povratak na korisnika", + "LabelBackupLocation": "Lokacija sigurnosnih kopija", + "LabelBackupsEnableAutomaticBackups": "Uključi automatsku izradu sigurnosnih kopija", + "LabelBackupsEnableAutomaticBackupsHelp": "Sigurnosne kopije spremaju se u /metadata/backups", + "LabelBackupsMaxBackupSize": "Maksimalna veličina sigurnosne kopije (u GB) (0 za neograničeno)", + "LabelBackupsMaxBackupSizeHelp": "U svrhu sprečavanja izrade krive konfiguracije, sigurnosne kopije neće se izraditi ako su veće od zadane veličine.", + "LabelBackupsNumberToKeep": "Broj sigurnosnih kopija za čuvanje", + "LabelBackupsNumberToKeepHelp": "Moguće je izbrisati samo jednu po jednu sigurnosnu kopiju, ako ih već imate više trebat ćete ih ručno ukloniti.", + "LabelBitrate": "Protok", "LabelBooks": "Knjige", - "LabelButtonText": "Button Text", - "LabelByAuthor": "by {0}", + "LabelButtonText": "Tekst gumba", + "LabelByAuthor": "po {0}", "LabelChangePassword": "Promijeni lozinku", - "LabelChannels": "Channels", - "LabelChapterTitle": "Ime poglavlja", + "LabelChannels": "Kanali", + "LabelChapterTitle": "Naslov poglavlja", "LabelChapters": "Poglavlja", - "LabelChaptersFound": "poglavlja pronađena", - "LabelClickForMoreInfo": "Click for more info", - "LabelClosePlayer": "Zatvori izvođač", - "LabelCodec": "Codec", - "LabelCollapseSeries": "Serijal prikaži skraćeno", - "LabelCollection": "Collection", - "LabelCollections": "Kolekcije", + "LabelChaptersFound": "poglavlja pronađeno", + "LabelClickForMoreInfo": "Kliknite za više informacija", + "LabelClosePlayer": "Zatvori reproduktor", + "LabelCodec": "Kodek", + "LabelCollapseSeries": "Serijal prikaži sažeto", + "LabelCollapseSubSeries": "Podserijal prikaži sažeto", + "LabelCollection": "Zbirka", + "LabelCollections": "Zbirke", "LabelComplete": "Dovršeno", "LabelConfirmPassword": "Potvrdi lozinku", "LabelContinueListening": "Nastavi slušati", "LabelContinueReading": "Nastavi čitati", "LabelContinueSeries": "Nastavi serijal", - "LabelCover": "Cover", - "LabelCoverImageURL": "URL od covera", - "LabelCreatedAt": "Napravljeno", - "LabelCronExpression": "Cron Expression", + "LabelCover": "Naslovnica", + "LabelCoverImageURL": "URL naslovnice", + "LabelCreatedAt": "Stvoreno", + "LabelCronExpression": "Cron izraz", "LabelCurrent": "Trenutan", "LabelCurrently": "Trenutno:", - "LabelCustomCronExpression": "Custom Cron Expression:", - "LabelDatetime": "Datetime", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelCustomCronExpression": "Prilagođeni CRON izraz:", + "LabelDatetime": "Datum i vrijeme", + "LabelDays": "Dani", + "LabelDeleteFromFileSystemCheckbox": "Izbriši datoteke (uklonite oznaku ako stavku želite izbrisati samo iz baze podataka)", "LabelDescription": "Opis", "LabelDeselectAll": "Odznači sve", "LabelDevice": "Uređaj", "LabelDeviceInfo": "O uređaju", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "Uređaj je dostupan...", "LabelDirectory": "Direktorij", - "LabelDiscFromFilename": "CD iz imena datoteke", - "LabelDiscFromMetadata": "CD iz metapodataka", + "LabelDiscFromFilename": "Disk iz imena datoteke", + "LabelDiscFromMetadata": "Disk iz metapodataka", "LabelDiscover": "Otkrij", "LabelDownload": "Preuzmi", - "LabelDownloadNEpisodes": "Download {0} episodes", + "LabelDownloadNEpisodes": "Preuzmi {0} nastavak/a", "LabelDuration": "Trajanje", - "LabelDurationComparisonExactMatch": "(exact match)", - "LabelDurationComparisonLonger": "({0} longer)", - "LabelDurationComparisonShorter": "({0} shorter)", + "LabelDurationComparisonExactMatch": "(točno podudaranje)", + "LabelDurationComparisonLonger": "({0} duže)", + "LabelDurationComparisonShorter": "({0} kraće)", "LabelDurationFound": "Pronađeno trajanje:", - "LabelEbook": "Elektronička knjiga", - "LabelEbooks": "Elektroničke knjige", + "LabelEbook": "E-knjiga", + "LabelEbooks": "E-knjige", "LabelEdit": "Uredi", - "LabelEmail": "Email", - "LabelEmailSettingsFromAddress": "From Address", - "LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates", - "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.", - "LabelEmailSettingsSecure": "Secure", - "LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)", - "LabelEmailSettingsTestAddress": "Test Address", - "LabelEmbeddedCover": "Embedded Cover", + "LabelEmail": "E-pošta", + "LabelEmailSettingsFromAddress": "Adresa pošiljatelja", + "LabelEmailSettingsRejectUnauthorized": "Odbij neovjerene certifikate", + "LabelEmailSettingsRejectUnauthorizedHelp": "Onemogućavanjem ovjere SSL certifikata izlažete vezu sigurnosnim rizicima, poput MITM napada. Ovu opciju isključite samo ukoliko razumijete što ona znači i vjerujete poslužitelju e-pošte s kojim se povezujete.", + "LabelEmailSettingsSecure": "Sigurno", + "LabelEmailSettingsSecureHelp": "Ako je uključeno, prilikom spajanja na poslužitelj upotrebljavat će se TLS. Ako je isključeno, TLS se upotrebljava samo ako poslužitelj podržava STARTTLS proširenje. U većini slučajeva, ovu ćete vrijednost uključiti ako se spajate na priključak 465. Za priključke 587 ili 25 ostavite je isključenom. (Izvor: nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Probna adresa", + "LabelEmbeddedCover": "Ugrađena naslovnica", "LabelEnable": "Omogući", "LabelEnd": "Kraj", "LabelEndOfChapter": "Kraj poglavlja", "LabelEpisode": "Nastavak", - "LabelEpisodeTitle": "Naslov epizode", - "LabelEpisodeType": "Vrsta epizode", - "LabelExample": "Example", - "LabelExplicit": "Explicit", - "LabelExplicitChecked": "Explicit (checked)", - "LabelExplicitUnchecked": "Not Explicit (unchecked)", + "LabelEpisodeTitle": "Naslov nastavka", + "LabelEpisodeType": "Vrsta nastavka", + "LabelEpisodes": "Nastavci", + "LabelExample": "Primjer", + "LabelExpandSeries": "Serijal prikaži prošireno", + "LabelExpandSubSeries": "Podserijal prikaži prošireno", + "LabelExplicit": "Eksplicitni sadržaj", + "LabelExplicitChecked": "Eksplicitni sadržaj (označeno)", + "LabelExplicitUnchecked": "Nije eksplicitni sadržaj (odznačeno)", + "LabelExportOPML": "Izvoz OPML-a", "LabelFeedURL": "URL izvora", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "Dohvaćanje meta-podataka", "LabelFile": "Datoteka", "LabelFileBirthtime": "Vrijeme stvaranja datoteke", + "LabelFileBornDate": "Stvoreno {0}", "LabelFileModified": "Datoteka izmijenjena", + "LabelFileModifiedDate": "Izmijenjeno {0}", "LabelFilename": "Naziv datoteke", "LabelFilterByUser": "Filtriraj po korisniku", "LabelFindEpisodes": "Pronađi epizode", "LabelFinished": "Dovršeno", "LabelFolder": "Mapa", - "LabelFolders": "Folderi", - "LabelFontBold": "Bold", + "LabelFolders": "Mape", + "LabelFontBold": "Podebljano", "LabelFontBoldness": "Debljina slova", - "LabelFontFamily": "Font family", - "LabelFontItalic": "Italic", + "LabelFontFamily": "Skupina fontova", + "LabelFontItalic": "Kurziv", "LabelFontScale": "Veličina slova", - "LabelFontStrikethrough": "Strikethrough", + "LabelFontStrikethrough": "Precrtano", "LabelFormat": "Format", "LabelGenre": "Žanr", "LabelGenres": "Žanrovi", "LabelHardDeleteFile": "Obriši datoteku zauvijek", "LabelHasEbook": "Ima e-knjigu", "LabelHasSupplementaryEbook": "Ima dopunsku e-knjigu", - "LabelHighestPriority": "Highest priority", + "LabelHideSubtitles": "Skrij podnaslove", + "LabelHighestPriority": "Najviši prioritet", "LabelHost": "Poslužitelj", "LabelHour": "Sat", + "LabelHours": "Sati", "LabelIcon": "Ikona", - "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelImageURLFromTheWeb": "URL slike s weba", "LabelInProgress": "U tijeku", - "LabelIncludeInTracklist": "Dodaj u Tracklist", + "LabelIncludeInTracklist": "Uključi u popisu zvučnih zapisa", "LabelIncomplete": "Nepotpuno", "LabelInterval": "Interval", - "LabelIntervalCustomDailyWeekly": "Custom daily/weekly", - "LabelIntervalEvery12Hours": "Every 12 hours", - "LabelIntervalEvery15Minutes": "Every 15 minutes", - "LabelIntervalEvery2Hours": "Every 2 hours", - "LabelIntervalEvery30Minutes": "Every 30 minutes", - "LabelIntervalEvery6Hours": "Every 6 hours", - "LabelIntervalEveryDay": "Every day", - "LabelIntervalEveryHour": "Every hour", - "LabelInvert": "Invert", + "LabelIntervalCustomDailyWeekly": "Prilagođeno dnevno/tjedno", + "LabelIntervalEvery12Hours": "Svakih 12 sati", + "LabelIntervalEvery15Minutes": "Svakih 15 minuta", + "LabelIntervalEvery2Hours": "Svaka 2 sata", + "LabelIntervalEvery30Minutes": "Svakih 30 minuta", + "LabelIntervalEvery6Hours": "Svakih 6 sati", + "LabelIntervalEveryDay": "Svaki dan", + "LabelIntervalEveryHour": "Svaki sat", + "LabelInvert": "Obrni", "LabelItem": "Stavka", + "LabelJumpBackwardAmount": "Dužina skoka unatrag", + "LabelJumpForwardAmount": "Dužina skoka unaprijed", "LabelLanguage": "Jezik", - "LabelLanguageDefaultServer": "Default jezik servera", - "LabelLanguages": "Languages", - "LabelLastBookAdded": "Last Book Added", - "LabelLastBookUpdated": "Last Book Updated", + "LabelLanguageDefaultServer": "Zadani jezik poslužitelja", + "LabelLanguages": "Jezici", + "LabelLastBookAdded": "Zadnja dodana knjiga", + "LabelLastBookUpdated": "Zadnja ažurirana knjiga", "LabelLastSeen": "Zadnje pogledano", "LabelLastTime": "Prošli put", "LabelLastUpdate": "Zadnja aktualizacija", "LabelLayout": "Prikaz", "LabelLayoutSinglePage": "Jedna stranica", - "LabelLayoutSplitPage": "Split page", + "LabelLayoutSplitPage": "Podijeli stranicu", "LabelLess": "Manje", - "LabelLibrariesAccessibleToUser": "Biblioteke pristupačne korisniku", - "LabelLibrary": "Biblioteka", - "LabelLibraryFilterSublistEmpty": "No {0}", - "LabelLibraryItem": "Stavka biblioteke", - "LabelLibraryName": "Ime biblioteke", - "LabelLimit": "Limit", + "LabelLibrariesAccessibleToUser": "Knjižnice dostupne korisniku", + "LabelLibrary": "Knjižnica", + "LabelLibraryFilterSublistEmpty": "Br {0}", + "LabelLibraryItem": "Stavka knjižnice", + "LabelLibraryName": "Ime knjižnice", + "LabelLimit": "Ograničenje", "LabelLineSpacing": "Razmak između redaka", "LabelListenAgain": "Ponovno poslušaj", "LabelLogLevelDebug": "Debug", "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma", - "LabelLowestPriority": "Lowest Priority", - "LabelMatchExistingUsersBy": "Match existing users by", - "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", - "LabelMediaPlayer": "Media Player", + "LabelLowestPriority": "Najniži prioritet", + "LabelMatchExistingUsersBy": "Prepoznaj postojeće korisnike pomoću", + "LabelMatchExistingUsersByDescription": "Rabi se za povezivanje postojećih korisnika. Nakon što se spoje, korisnike se prepoznaje temeljem jedinstvene oznake vašeg pružatelja SSO usluga", + "LabelMediaPlayer": "Reproduktor medijskih sadržaja", "LabelMediaType": "Vrsta medijskog zapisa", - "LabelMetaTag": "Meta Tag", - "LabelMetaTags": "Meta Tags", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", - "LabelMetadataProvider": "Poslužitelj metapodataka ", + "LabelMetaTag": "Meta oznaka", + "LabelMetaTags": "Meta oznake", + "LabelMetadataOrderOfPrecedenceDescription": "Izvori meta-podataka višeg prioriteta nadjačat će izvore nižeg prioriteta", + "LabelMetadataProvider": "Pružatelj meta-podataka", "LabelMinute": "Minuta", + "LabelMinutes": "Minute", "LabelMissing": "Nedostaje", - "LabelMissingEbook": "Has no ebook", - "LabelMissingSupplementaryEbook": "Has no supplementary ebook", - "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", - "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", + "LabelMissingEbook": "Nema e-knjigu", + "LabelMissingSupplementaryEbook": "Nema dopunsku e-knjigu", + "LabelMobileRedirectURIs": "Dopušteni URI-ji za preusmjeravanje mobilne aplikacije", + "LabelMobileRedirectURIsDescription": "Ovo je popis dopuštenih važećih URI-ja za preusmjeravanje mobilne aplikacije. Zadana vrijednost je audiobookshelf://oauth, nju možete ukloniti ili dopuniti dodatnim URI-jima za integraciju aplikacija trećih strana. Upisom zvjezdice (*) kao jedinim unosom možete dozvoliti bilo koji URI.", "LabelMore": "Više", "LabelMoreInfo": "Više informacija", "LabelName": "Ime", @@ -389,139 +412,147 @@ "LabelNewPassword": "Nova lozinka", "LabelNewestAuthors": "Najnoviji autori", "LabelNewestEpisodes": "Najnoviji nastavci", - "LabelNextBackupDate": "Next backup date", - "LabelNextScheduledRun": "Next scheduled run", - "LabelNoCustomMetadataProviders": "No custom metadata providers", - "LabelNoEpisodesSelected": "No episodes selected", + "LabelNextBackupDate": "Datum sljedeće izrade sigurnosne kopije", + "LabelNextScheduledRun": "Sljedeće zakazano izvođenje", + "LabelNoCustomMetadataProviders": "Nema prilagođenih pružatelja meta-podataka", + "LabelNoEpisodesSelected": "Nema odabranih nastavaka", "LabelNotFinished": "Nije dovršeno", "LabelNotStarted": "Nije započeto", "LabelNotes": "Bilješke", - "LabelNotificationAppriseURL": "Apprise URL(s)", + "LabelNotificationAppriseURL": "Apprise URL(ovi)", "LabelNotificationAvailableVariables": "Dostupne varijable", - "LabelNotificationBodyTemplate": "Body Template", - "LabelNotificationEvent": "Notification Event", - "LabelNotificationTitleTemplate": "Title Template", + "LabelNotificationBodyTemplate": "Predložak sadržaja", + "LabelNotificationEvent": "Događaj za obavijest", + "LabelNotificationTitleTemplate": "Predložak naslova", "LabelNotificationsMaxFailedAttempts": "Maksimalan broj neuspjelih pokušaja", - "LabelNotificationsMaxFailedAttemptsHelp": "Obavijesti će biti isključene ako par puta budu neuspješno poslane.", - "LabelNotificationsMaxQueueSize": "Maksimalna veličina queuea za notification events", - "LabelNotificationsMaxQueueSizeHelp": "Samo 1 event po sekundi može biti pokrenut. Eventi će biti ignorirani ako je queue na maksimalnoj veličini. To spriječava spammanje s obavijestima.", - "LabelNumberOfBooks": "Number of Books", - "LabelNumberOfEpisodes": "# of Episodes", - "LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (if configured). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as false. Ensure the identity provider's claim matches the expected structure:", - "LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.", - "LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as groups. If configured, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.", + "LabelNotificationsMaxFailedAttemptsHelp": "Obavijesti će biti isključene ako slanje ne uspije nakon ovoliko pokušaja", + "LabelNotificationsMaxQueueSize": "Najveći broj događaja za obavijest u redu čekanja", + "LabelNotificationsMaxQueueSizeHelp": "Događaji se mogu okinuti samo jednom u sekundi. Događaji će se zanemariti ako je red čekanja pun. Ovo sprečava prekomjerno slanje obavijesti.", + "LabelNumberOfBooks": "Broj knjiga", + "LabelNumberOfEpisodes": "broj nastavaka", + "LabelOpenIDAdvancedPermsClaimDescription": "Naziv OpenID zahtjeva koji sadrži napredna dopuštenja za korisničke radnje u aplikaciji koje će se primijeniti na ne-administratorske uloge (ako su konfigurirane). Ako zahtjev nedostaje u odgovoru, pristup ABS-u neće se odobriti. Ako i jedna opcija nedostaje, smatrat će se da je false. Pripazite da zahtjev pružatelja identiteta uvijek odgovara očekivanoj strukturi:", + "LabelOpenIDClaims": "Sljedeće opcije ostavite praznima ako želite onemogućiti napredno dodjeljivanje grupa i dozvola, odnosno ako želite automatski dodijeliti grupu 'korisnik'.", + "LabelOpenIDGroupClaimDescription": "Naziv OpenID zahtjeva koji sadrži popis korisnikovih grupa. Često se naziva groups. Ako se konfigurira, aplikacija će automatski dodijeliti uloge temeljem korisnikovih članstava u grupama, pod uvjetom da se iste zovu 'admin', 'user' ili 'guest' u zahtjevu (ne razlikuju se velika i mala slova). Zahtjev treba sadržavati popis i ako je korisnik član više grupa, aplikacija će dodijeliti ulogu koja odgovara najvišoj razini pristupa. Ukoliko se niti jedna grupa ne podudara, pristup će biti onemogućen.", "LabelOpenRSSFeed": "Otvori RSS Feed", - "LabelOverwrite": "Overwrite", + "LabelOverwrite": "Prepiši", "LabelPassword": "Zaporka", "LabelPath": "Putanja", - "LabelPermissionsAccessAllLibraries": "Ima pristup svim bibliotekama", - "LabelPermissionsAccessAllTags": "Ima pristup svim tagovima", + "LabelPermanent": "Trajno", + "LabelPermissionsAccessAllLibraries": "Ima pristup svim knjižnicama", + "LabelPermissionsAccessAllTags": "Ima pristup svim oznakama", "LabelPermissionsAccessExplicitContent": "Ima pristup eksplicitnom sadržzaju", "LabelPermissionsDelete": "Smije brisati", "LabelPermissionsDownload": "Smije preuzimati", - "LabelPermissionsUpdate": "Smije aktualizirati", - "LabelPermissionsUpload": "Smije uploadati", - "LabelPersonalYearReview": "Your Year in Review ({0})", - "LabelPhotoPathURL": "Slika putanja/URL", - "LabelPlayMethod": "Vrsta reprodukcije", - "LabelPlayerChapterNumberMarker": "{0} of {1}", - "LabelPlaylists": "Playlists", + "LabelPermissionsUpdate": "Smije ažurirati", + "LabelPermissionsUpload": "Smije učitavati", + "LabelPersonalYearReview": "Vaš godišnji pregled ({0})", + "LabelPhotoPathURL": "Putanja ili URL fotografije", + "LabelPlayMethod": "Način reprodukcije", + "LabelPlayerChapterNumberMarker": "{0} od {1}", + "LabelPlaylists": "Popisi za izvođenje", "LabelPodcast": "Podcast", - "LabelPodcastSearchRegion": "Područje pretrage podcasta", - "LabelPodcastType": "Podcast Type", + "LabelPodcastSearchRegion": "Zemljopisno područje kod pretraživanja podcasta", + "LabelPodcastType": "Vrsta podcasta", "LabelPodcasts": "Podcasti", - "LabelPort": "Port", - "LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)", - "LabelPreventIndexing": "Onemogućite da iTunes i Google indeksiraju vaš feed za svoje popise podcasta", - "LabelPrimaryEbook": "Primary ebook", + "LabelPort": "Priključak", + "LabelPrefixesToIgnore": "Prefiksi koji se zanemaruju (mala i velika slova nisu bitna)", + "LabelPreventIndexing": "Spriječite da iTunes i Google indeksiraju vaš feed za svoje popise podcasta", + "LabelPrimaryEbook": "Primarna e-knjiga", "LabelProgress": "Napredak", "LabelProvider": "Dobavljač", + "LabelProviderAuthorizationValue": "Vrijednost autorizacijskog zaglavlja", "LabelPubDate": "Datum izdavanja", "LabelPublishYear": "Godina izdavanja", + "LabelPublishedDate": "Objavljeno {0}", "LabelPublisher": "Izdavač", - "LabelPublishers": "Publishers", + "LabelPublishers": "Izdavači", "LabelRSSFeedCustomOwnerEmail": "Prilagođena adresa e-pošte vlasnika", "LabelRSSFeedCustomOwnerName": "Prilagođeno ime vlasnika", - "LabelRSSFeedOpen": "RSS Feed Open", + "LabelRSSFeedOpen": "RSS izvor otvoren", "LabelRSSFeedPreventIndexing": "Onemogući indeksiranje", "LabelRSSFeedSlug": "Slug RSS izvora", - "LabelRSSFeedURL": "RSS Feed URL", + "LabelRSSFeedURL": "URL RSS izvora", "LabelRandomly": "Nasumično", + "LabelReAddSeriesToContinueListening": "Ponovno dodaj serijal u Nastavi slušati", "LabelRead": "Čitaj", "LabelReadAgain": "Ponovno čitaj", - "LabelReadEbookWithoutProgress": "Read ebook without keeping progress", + "LabelReadEbookWithoutProgress": "Čitaj e-knjige bez praćenja napretka", "LabelRecentSeries": "Nedavni serijal", "LabelRecentlyAdded": "Nedavno dodano", - "LabelRecommended": "Recommended", - "LabelRedo": "Redo", + "LabelRecommended": "Preporučeno", + "LabelRedo": "Ponovi", "LabelRegion": "Regija", "LabelReleaseDate": "Datum izlaska", - "LabelRemoveCover": "Remove cover", - "LabelRowsPerPage": "Rows per page", + "LabelRemoveCover": "Ukloni naslovnicu", + "LabelRowsPerPage": "Redaka po stranici", "LabelSearchTerm": "Traži pojam", "LabelSearchTitle": "Traži naslov", "LabelSearchTitleOrASIN": "Traži naslov ili ASIN", "LabelSeason": "Sezona", - "LabelSelectAll": "Select all", - "LabelSelectAllEpisodes": "Select all episodes", - "LabelSelectEpisodesShowing": "Select {0} episodes showing", - "LabelSelectUsers": "Select users", - "LabelSendEbookToDevice": "Send Ebook to...", - "LabelSequence": "Sekvenca", + "LabelSelectAll": "Označi sve", + "LabelSelectAllEpisodes": "Označi sve nastavke", + "LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka", + "LabelSelectUsers": "Označi korisnike", + "LabelSendEbookToDevice": "Pošalji e-knjigu", + "LabelSequence": "Slijed", "LabelSeries": "Serijali", - "LabelSeriesName": "Ime serije", - "LabelSeriesProgress": "Series Progress", - "LabelServerYearReview": "Server Year in Review ({0})", + "LabelSeriesName": "Ime serijala", + "LabelSeriesProgress": "Napredak u serijalu", + "LabelServerYearReview": "Godišnji pregled poslužitelja ({0})", "LabelSetEbookAsPrimary": "Postavi kao primarno", "LabelSetEbookAsSupplementary": "Postavi kao dopunsko", - "LabelSettingsAudiobooksOnly": "Audiobooks only", - "LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks", - "LabelSettingsBookshelfViewHelp": "Skeumorfski (što god to bilo) dizajn sa drvenim policama", - "LabelSettingsChromecastSupport": "Chromecast podrška", + "LabelSettingsAudiobooksOnly": "Samo zvučne knjige", + "LabelSettingsAudiobooksOnlyHelp": "Ako uključite ovu mogućnost, sustav će zanemariti datoteke e-knjiga ukoliko se ne nalaze u mapi zvučne knjige, gdje će se smatrati dopunskim e-knjigama", + "LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama", + "LabelSettingsChromecastSupport": "Podrška za Chromecast", "LabelSettingsDateFormat": "Format datuma", - "LabelSettingsDisableWatcher": "Isključi Watchera", - "LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku", - "LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera", - "LabelSettingsEnableWatcher": "Enable Watcher", - "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library", - "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", - "LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs", - "LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.", - "LabelSettingsExperimentalFeatures": "Eksperimentalni features", - "LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.", - "LabelSettingsFindCovers": "Pronađi covers", - "LabelSettingsFindCoversHelp": "Ako audiobook nema embedani cover or a cover sliku unutar foldera, skener će probati pronaći cover.
Bilješka: Ovo će produžiti trjanje skeniranja", - "LabelSettingsHideSingleBookSeries": "Hide single book series", - "LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.", - "LabelSettingsHomePageBookshelfView": "Koristi bookshelf pogled za početnu stranicu", - "LabelSettingsLibraryBookshelfView": "Koristi bookshelf pogled za biblioteku", - "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Skip earlier books in Continue Series", - "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "The Continue Series home page shelf shows the first book not started in series that have at least one book finished and no books in progress. Enabling this setting will continue series from the furthest completed book instead of the first book not started.", - "LabelSettingsParseSubtitles": "Parsaj podnapise", - "LabelSettingsParseSubtitlesHelp": "Izvadi podnapise iz imena od audiobook foldera.
Podnapis mora biti odvojen sa \" - \"
npr. \"Ime knjige - Podnapis ovdje\" ima podnapis \"Podnapis ovdje\"", - "LabelSettingsPreferMatchedMetadata": "Preferiraj matchane metapodatke", - "LabelSettingsPreferMatchedMetadataHelp": "Matchani podatci će biti korišteni kada se koristi Quick Match. Po defaultu Quick Match će ispuniti samo prazne detalje.", - "LabelSettingsSkipMatchingBooksWithASIN": "Preskoči matchanje knjiga koje već imaju ASIN", - "LabelSettingsSkipMatchingBooksWithISBN": "SPreskoči matchanje knjiga koje već imaju ISBN", - "LabelSettingsSortingIgnorePrefixes": "Zanemari prefikse tokom sortiranja", - "LabelSettingsSortingIgnorePrefixesHelp": "npr. za prefiks \"the\" book title \"The Ime Knjige\" će sortirati kao \"Ime Knjige, The\"", - "LabelSettingsSquareBookCovers": "Kockasti cover knjige", - "LabelSettingsSquareBookCoversHelp": "Koristi kockasti cover knjige umjesto klasičnog 1.6:1.", - "LabelSettingsStoreCoversWithItem": "Spremi cover uz stakvu", - "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", + "LabelSettingsDisableWatcher": "Isključi praćenje datotečnog sustava", + "LabelSettingsDisableWatcherForLibrary": "Onemogući praćenje datotečnog sustava za ovu knjižnicu", + "LabelSettingsDisableWatcherHelp": "Onemogućuje automatsko dodavanje ili ažuriranje stavki kod uočenih promjena datoteka. *Potrebno je ponovno pokrenuti poslužitelj", + "LabelSettingsEnableWatcher": "Omogući praćenje", + "LabelSettingsEnableWatcherForLibrary": "Omogući praćenje promjena u mapi knjižnice", + "LabelSettingsEnableWatcherHelp": "Omogućuje automatsko dodavanje/ažuriranje stavki kada se uoče izmjene datoteka. *Potrebno je ponovno pokretanje poslužitelja", + "LabelSettingsEpubsAllowScriptedContent": "Omogući skripte u epub datotekama", + "LabelSettingsEpubsAllowScriptedContentHelp": "Omogućuje epub datotekama izvođenje skripti. Preporučamo isključiti ovu mogućnost ukoliko nemate povjerenja u izvore epub datoteka.", + "LabelSettingsExperimentalFeatures": "Eksperimentalne značajke", + "LabelSettingsExperimentalFeaturesHelp": "Značajke u razvoju za koje trebamo vaše povratne informacije i pomoć u testiranju. Kliknite za otvaranje rasprave na githubu.", + "LabelSettingsFindCovers": "Pronađi naslovnice", + "LabelSettingsFindCoversHelp": "Ako vaša zvučna knjiga nema ugrađenu naslovnicu ili sliku naslovnice u mapi, skener će pokušati pronaći naslovnicu.
Napomena: ovo će produžiti trajanje skeniranja", + "LabelSettingsHideSingleBookSeries": "Skrij serijale sa samo jednom knjigom", + "LabelSettingsHideSingleBookSeriesHelp": "Serijali koji se sastoje od samo jedne knjige neće se prikazivati na stranici serijala i na policama početne stranice.", + "LabelSettingsHomePageBookshelfView": "Prikaži početnu stranicu kao policu s knjigama", + "LabelSettingsLibraryBookshelfView": "Prikaži knjižnicu kao policu s knjigama", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u Nastavi serijal", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", + "LabelSettingsParseSubtitles": "Raščlani podnaslove", + "LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.
Podnaslov mora biti odvojen s \" - \"
npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"", + "LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki", + "LabelSettingsPreferMatchedMetadataHelp": "Podatci prepoznatog naslova nadjačat će postojeće informacije kod korištenja funkcije Brzog prepoznavanja. Zadana funkcionalnost je da Brzo prepoznavanje samo dopuni podatke koji nedostaju.", + "LabelSettingsSkipMatchingBooksWithASIN": "Preskoči prepoznavanje knjiga koje već imaju ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Preskoči prepoznavanje knjiga koje već imaju ISBN", + "LabelSettingsSortingIgnorePrefixes": "Zanemari prefikse kod sortiranja", + "LabelSettingsSortingIgnorePrefixesHelp": "npr. za prefiks \"the\" naslov knjige \"The Book Title\" sortirat će se \"Book Title, The\"", + "LabelSettingsSquareBookCovers": "Koristi pravokutne naslovnice knjiga", + "LabelSettingsSquareBookCoversHelp": "Koristi pravokutne naslovnice umjesto uobičajenih naslovnica omjera 1,6:1", + "LabelSettingsStoreCoversWithItem": "Spremi naslovnice uz stavke", + "LabelSettingsStoreCoversWithItemHelp": "Naslovnice se obično spremaju u /metadata/items, ako uključite ovu opciju naslovnice će se spremati u mapu knjižničke stavke. Čuva se samo jedna datoteka naziva \"cover\"", "LabelSettingsStoreMetadataWithItem": "Spremi metapodatke uz stavku", - "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke", - "LabelSettingsTimeFormat": "Time Format", + "LabelSettingsStoreMetadataWithItemHelp": "Meta-podatci se obično spremaju u /metadata/items; ako uključite ovu postavku meta-podatci će se čuvati u mapama knjižničkih stavki", + "LabelSettingsTimeFormat": "Format vremena", + "LabelShare": "Podijeli", + "LabelShareOpen": "Podijeli otvoreno", + "LabelShareURL": "URL za dijeljenje", "LabelShowAll": "Prikaži sve", - "LabelShowSeconds": "Show seconds", + "LabelShowSeconds": "Prikaži sekunde", + "LabelShowSubtitles": "Pokaži podnaslove", "LabelSize": "Veličina", "LabelSleepTimer": "Timer za spavanje", "LabelSlug": "Slug", "LabelStart": "Pokreni", - "LabelStartTime": "Vrijeme pokretanja", - "LabelStarted": "Pokrenuto", - "LabelStartedAt": "Pokrenuto", - "LabelStatsAudioTracks": "Audio Tracks", + "LabelStartTime": "Vrijeme početka", + "LabelStarted": "Započeto", + "LabelStartedAt": "Započeto", + "LabelStatsAudioTracks": "Zvučni zapisi", "LabelStatsAuthors": "Autori", "LabelStatsBestDay": "Najbolji dan", "LabelStatsDailyAverage": "Dnevni prosjek", @@ -533,294 +564,413 @@ "LabelStatsItemsInLibrary": "Stavke u biblioteki", "LabelStatsMinutes": "minute", "LabelStatsMinutesListening": "Minuta odslušano", - "LabelStatsOverallDays": "Overall Days", - "LabelStatsOverallHours": "Overall Hours", + "LabelStatsOverallDays": "Ukupno dana", + "LabelStatsOverallHours": "Ukupno sati", "LabelStatsWeekListening": "Tjedno slušanje", - "LabelSubtitle": "Podnapis", - "LabelSupportedFileTypes": "Podržtani tip datoteke", + "LabelSubtitle": "Podnaslov", + "LabelSupportedFileTypes": "Podržane vrste datoteka", "LabelTag": "Oznaka", "LabelTags": "Oznake", - "LabelTagsAccessibleToUser": "Tags dostupni korisniku", - "LabelTagsNotAccessibleToUser": "Tags not Accessible to User", - "LabelTasks": "Tasks Running", - "LabelTextEditorBulletedList": "Bulleted list", - "LabelTextEditorLink": "Link", - "LabelTextEditorNumberedList": "Numbered list", - "LabelTextEditorUnlink": "Unlink", + "LabelTagsAccessibleToUser": "Oznake dostupne korisniku", + "LabelTagsNotAccessibleToUser": "Oznake nedostupne korisniku", + "LabelTasks": "Zadatci koji se izvode", + "LabelTextEditorBulletedList": "Popis s grafičkim oznakama", + "LabelTextEditorLink": "Poveznica", + "LabelTextEditorNumberedList": "Numerirani popis", + "LabelTextEditorUnlink": "Prekini vezu", "LabelTheme": "Tema", "LabelThemeDark": "Tamna", "LabelThemeLight": "Svijetla", - "LabelTimeBase": "Time Base", + "LabelTimeBase": "Baza vremena", + "LabelTimeDurationXHours": "{0} sati", + "LabelTimeDurationXMinutes": "{0} minuta", + "LabelTimeDurationXSeconds": "{0} sekundi", + "LabelTimeInMinutes": "Vrijeme u minutama", "LabelTimeListened": "Vremena odslušano", "LabelTimeListenedToday": "Vremena odslušano danas", "LabelTimeRemaining": "{0} preostalo", "LabelTimeToShift": "Vrijeme za pomjeriti u sekundama", "LabelTitle": "Naslov", - "LabelToolsEmbedMetadata": "Embed Metadata", - "LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.", - "LabelToolsMakeM4b": "Make M4B Audiobook File", - "LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.", - "LabelToolsSplitM4b": "Split M4B to MP3's", - "LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.", - "LabelTotalDuration": "Total Duration", + "LabelToolsEmbedMetadata": "Ugradi meta-podatke", + "LabelToolsEmbedMetadataDescription": "Ugradi meta-podatke u zvučne datoteke zajedno s naslovnicom i poglavljima.", + "LabelToolsMakeM4b": "Stvori M4B datoteku audioknjige", + "LabelToolsMakeM4bDescription": "Izrađuje zvučnu knjigu u .M4B formatu s ugrađenim meta-podatcima, naslovnicom i poglavljima.", + "LabelToolsSplitM4b": "Podijeli M4B datoteke u MP3 datoteke", + "LabelToolsSplitM4bDescription": "Stvara MP3 datoteke dijeljenjem M4B datoteke po poglavljima, s ugrađenim meta-podatcima, slikom naslovnice i poglavljima.", + "LabelTotalDuration": "Ukupno trajanje", "LabelTotalTimeListened": "Sveukupno vrijeme slušanja", - "LabelTrackFromFilename": "Track iz imena datoteke", - "LabelTrackFromMetadata": "Track iz metapodataka", - "LabelTracks": "Zapisi", - "LabelTracksMultiTrack": "Multi-track", - "LabelTracksNone": "No tracks", - "LabelTracksSingleTrack": "Single-track", + "LabelTrackFromFilename": "Naslov iz imena datoteke", + "LabelTrackFromMetadata": "Naslov iz meta-podataka", + "LabelTracks": "Naslovi", + "LabelTracksMultiTrack": "Više zvučnih zapisa", + "LabelTracksNone": "Nema zapisa", + "LabelTracksSingleTrack": "Jedan zvučni zapis", "LabelType": "Vrsta", - "LabelUnabridged": "Unabridged", - "LabelUndo": "Undo", + "LabelUnabridged": "Neskraćena", + "LabelUndo": "Vrati", "LabelUnknown": "Nepoznato", - "LabelUpdateCover": "Aktualiziraj Cover", - "LabelUpdateCoverHelp": "Dozvoli postavljanje novog covera za odabrane knjige nakon što je match pronađen.", - "LabelUpdateDetails": "Aktualiziraj detalje", - "LabelUpdateDetailsHelp": "Dozvoli postavljanje novih detalja za odabrane knjige nakon što je match pronađen", - "LabelUpdatedAt": "Aktualizirano", - "LabelUploaderDragAndDrop": "Drag & Drop datoteke ili foldere", - "LabelUploaderDropFiles": "Ubaci datoteke", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", - "LabelUseChapterTrack": "Koristi poglavlja track", - "LabelUseFullTrack": "Koristi cijeli track", + "LabelUnknownPublishDate": "Nepoznat datum objavljivanja", + "LabelUpdateCover": "Ažuriraj naslovnicu", + "LabelUpdateCoverHelp": "Dozvoli prepisivanje postojećih naslovnica za odabrane knjige kada se prepoznaju", + "LabelUpdateDetails": "Ažuriraj pojedinosti", + "LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju", + "LabelUpdatedAt": "Ažurirano", + "LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape", + "LabelUploaderDropFiles": "Ispusti datoteke", + "LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal", + "LabelUseChapterTrack": "Koristi zvučni zapis poglavlja", + "LabelUseFullTrack": "Koristi cijeli zvučni zapis", "LabelUser": "Korisnik", "LabelUsername": "Korisničko ime", "LabelValue": "Vrijednost", "LabelVersion": "Verzija", - "LabelViewBookmarks": "View bookmarks", - "LabelViewChapters": "View chapters", - "LabelViewQueue": "View player queue", - "LabelVolume": "Volume", - "LabelWeekdaysToRun": "Radnih dana da radi", - "LabelYearReviewHide": "Hide Year in Review", - "LabelYearReviewShow": "See Year in Review", - "LabelYourAudiobookDuration": "Tvoje trajanje audiobooka", + "LabelViewBookmarks": "Pogledaj knjižne oznake", + "LabelViewChapters": "Pogledaj poglavlja", + "LabelViewPlayerSettings": "Pogledaj postavke reproduktora", + "LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora", + "LabelVolume": "Glasnoća", + "LabelWeekdaysToRun": "Dani u tjednu za pokretanje", + "LabelXBooks": "{0} knjiga", + "LabelXItems": "{0} stavki", + "LabelYearReviewHide": "Ne prikazuj Godišnji pregled", + "LabelYearReviewShow": "Pogledaj Godišnji pregled", + "LabelYourAudiobookDuration": "Trajanje vaših zvučnih knjiga", "LabelYourBookmarks": "Vaše knjižne oznake", - "LabelYourPlaylists": "Your Playlists", + "LabelYourPlaylists": "Vaši popisi za izvođenje", "LabelYourProgress": "Vaš napredak", - "MessageAddToPlayerQueue": "Add to player queue", - "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", + "MessageAddToPlayerQueue": "Dodaj u redoslijed izvođenja", + "MessageAppriseDescription": "Da biste se koristili ovom značajkom, treba vam instanca Apprise API-ja ili API koji može rukovati istom vrstom zahtjeva.
The Adresa Apprise API-ja treba biti puna URL putanja za slanje obavijesti, npr. ako vam se API instanca poslužuje na adresi http://192.168.1.1:8337 trebate upisati http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Backups uključuju korisnike, korisnikov napredak, detalje stavki iz biblioteke, postavke server i slike iz /metadata/items & /metadata/authors. Backups ne uključuju nijedne datoteke koje su u folderima biblioteke.", - "MessageBatchQuickMatchDescription": "Quick Match će probati dodati nedostale covere i metapodatke za odabrane stavke. Uključi postavke ispod da omočutie Quick Mathchu da zamijeni postojeće covere i/ili metapodatke.", - "MessageBookshelfNoCollections": "You haven't made any collections yet", - "MessageBookshelfNoRSSFeeds": "No RSS feeds are open", - "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", - "MessageBookshelfNoResultsForQuery": "No results for query", - "MessageBookshelfNoSeries": "You have no series", - "MessageChapterEndIsAfter": "Kraj poglavlja je nakon kraja audioknjige.", - "MessageChapterErrorFirstNotZero": "First chapter must start at 0", - "MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration", - "MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", - "MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.", + "MessageBackupsLocationEditNote": "Napomena: Uređivanje lokacije za sigurnosne kopije ne premješta ili mijenja postojeće sigurnosne kopije", + "MessageBackupsLocationNoEditNote": "Napomena: Lokacija za sigurnosne kopije zadana je kroz varijablu okoline i ovdje se ne može izmijeniti.", + "MessageBackupsLocationPathEmpty": "Putanja do lokacije za sigurnosne kopije ne može ostati prazna", + "MessageBatchQuickMatchDescription": "Brzo prepoznavanje za odabrane će stavke pokušati dodati naslovnice i meta-podatke koji nedostaju. Uključite donje opcije ako želite da Brzo prepoznavanje prepiše postojeće naslovnice i/ili meta-podatke.", + "MessageBookshelfNoCollections": "Niste izradili niti jednu zbirku", + "MessageBookshelfNoRSSFeeds": "Nema otvorenih RSS izvora", + "MessageBookshelfNoResultsForFilter": "Nema rezultata za filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Vaš upit nema rezultata", + "MessageBookshelfNoSeries": "Nemate niti jedan serijal", + "MessageChapterEndIsAfter": "Kraj poglavlja je nakon kraja zvučne knjige", + "MessageChapterErrorFirstNotZero": "Prvo poglavlje mora započeti u 0", + "MessageChapterErrorStartGteDuration": "Netočno vrijeme početka, mora biti manje od trajanja zvučne knjige", + "MessageChapterErrorStartLtPrev": "Netočno vrijeme početka, mora biti veće ili jednako vremenu početka prethodnog poglavlja", + "MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja zvučne knjige.", "MessageCheckingCron": "Provjeravam cron...", - "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", + "MessageConfirmCloseFeed": "Sigurno želite zatvoriti ovaj izvor?", "MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?", - "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", - "MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", - "MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?", - "MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?", - "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", - "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", - "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", - "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", - "MessageConfirmPurgeCache": "Purge cache will delete the entire directory at /metadata/cache.

Are you sure you want to remove the cache directory?", - "MessageConfirmPurgeItemsCache": "Purge items cache will delete the entire directory at /metadata/cache/items.
Are you sure?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files.

Would you like to continue?", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", - "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", - "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", - "MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?", - "MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?", - "MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?", - "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", - "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", - "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", - "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", - "MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.", - "MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".", - "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", - "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", - "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", - "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", + "MessageConfirmDeleteDevice": "Sigurno želite izbrisati e-čitač \"{0}\"?", + "MessageConfirmDeleteFile": "Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?", + "MessageConfirmDeleteLibrary": "Sigurno želite trajno obrisati knjižnicu \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "Ovo će izbrisati knjižničku stavku iz datoteke i vašeg datotečnog sustava. Jeste li sigurni?", + "MessageConfirmDeleteLibraryItems": "Ovo će izbrisati {0} knjižničkih stavki iz baze podataka i datotečnog sustava. Jeste li sigurni?", + "MessageConfirmDeleteMetadataProvider": "Sigurno želite izbrisati prilagođenog pružatelja meta-podataka \"{0}\"?", + "MessageConfirmDeleteNotification": "Sigurno želite izbrisati ovu obavijest?", + "MessageConfirmDeleteSession": "Sigurno želite obrisati ovu sesiju?", + "MessageConfirmForceReScan": "Sigurno želite ponovno pokrenuti skeniranje?", + "MessageConfirmMarkAllEpisodesFinished": "Sigurno želite označiti sve nastavke dovršenima?", + "MessageConfirmMarkAllEpisodesNotFinished": "Sigurno želite označiti sve nastavke nedovršenima?", + "MessageConfirmMarkItemFinished": "Sigurno želite označiti \"{0}\" dovršenim?", + "MessageConfirmMarkItemNotFinished": "Sigurno želite označiti \"{0}\" nedovršenim?", + "MessageConfirmMarkSeriesFinished": "Sigurno želite označiti sve knjige u ovom serijalu dovršenima?", + "MessageConfirmMarkSeriesNotFinished": "Sigurno želite označiti sve knjige u ovom serijalu nedovršenima?", + "MessageConfirmNotificationTestTrigger": "Želite li okinuti ovu obavijest s probnim podatcima?", + "MessageConfirmPurgeCache": "Brisanje predmemorije izbrisat će cijelu mapu /metadata/cache.

Sigurno želite izbrisati mapu predmemorije?", + "MessageConfirmPurgeItemsCache": "Brisanje predmemorije stavki izbrisat će cijelu mapu /metadata/cache/items.
Jeste li sigurni?", + "MessageConfirmQuickEmbed": "Pažnja! Funkcija brzog ugrađivanja ne stvara sigurnosne kopije vaših zvučnih datoteka. Provjerite imate li sigurnosnu kopiju.

Želite li nastaviti?", + "MessageConfirmReScanLibraryItems": "Sigurno želite ponovno skenirati {0} stavki?", + "MessageConfirmRemoveAllChapters": "Sigurno želite ukloniti sva poglavlja?", + "MessageConfirmRemoveAuthor": "Sigurno želite ukloniti autora \"{0}\"?", + "MessageConfirmRemoveCollection": "Sigurno želite obrisati kolekciju \"{0}\"?", + "MessageConfirmRemoveEpisode": "Sigurno želite ukloniti nastavak \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Sigurno želite ukloniti {0} nastavaka?", + "MessageConfirmRemoveListeningSessions": "Sigurno želite ukloniti {0} sesija slušanja?", + "MessageConfirmRemoveNarrator": "Sigurno želite ukloniti pripovjedača \"{0}\"?", + "MessageConfirmRemovePlaylist": "Sigurno želite ukloniti vaš popis za izvođenje \"{0}\"?", + "MessageConfirmRenameGenre": "Sigurno želite preimenovati žanr \"{0}\" u \"{1}\" za sve stavke?", + "MessageConfirmRenameGenreMergeNote": "Napomena: Ovaj žanr već postoji, stoga će biti pripojen.", + "MessageConfirmRenameGenreWarning": "Pažnja! Sličan žanr s drugačijim velikim i malim slovima već postoji \"{0}\".", + "MessageConfirmRenameTag": "Sigurno želite preimenovati oznaku \"{0}\" u \"{1}\" za sve stavke?", + "MessageConfirmRenameTagMergeNote": "Napomena: Ova oznaka već postoji, stoga će biti pripojena.", + "MessageConfirmRenameTagWarning": "Pažnja! Slična oznaka s drugačijim velikim i malim slovima već postoji \"{0}\".", + "MessageConfirmResetProgress": "Sigurno želite resetirati napredak?", + "MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjiga/u \"{1}\" na uređaj \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?", "MessageDownloadingEpisode": "Preuzimam nastavak", - "MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.", - "MessageEmbedFinished": "Embed završen!", + "MessageDragFilesIntoTrackOrder": "Ispravi redoslijed zapisa prevlačenje datoteka", + "MessageEmbedFailed": "Ugrađivanje nije uspjelo!", + "MessageEmbedFinished": "Ugrađivanje je dovršeno!", "MessageEpisodesQueuedForDownload": "{0} nastavak(a) u redu za preuzimanje", - "MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.", + "MessageEreaderDevices": "Da biste osigurali isporuku e-knjiga, možda ćete morati gornju adresu e-pošte dodati kao dopuštenog pošiljatelja za svaki od donjih uređaja.", "MessageFeedURLWillBe": "URL izvora bit će {0}", "MessageFetching": "Dohvaćam...", - "MessageForceReScanDescription": "će skenirati sve datoteke ponovno kao svježi sken. ID3 tagovi od audio datoteka, OPF datoteke i tekst datoteke će biti skenirane kao da su nove.", + "MessageForceReScanDescription": "će ponovno skenirati sve datoteke kao nove datoteke. ID3 tagovi zvučnih datoteka, OPF datoteke i tekstualne datoteke skenirat će se kao da su nove.", "MessageImportantNotice": "Važna obavijest!", "MessageInsertChapterBelow": "Unesi poglavlje ispod", "MessageItemsSelected": "{0} odabranih stavki", - "MessageItemsUpdated": "{0} Items Updated", + "MessageItemsUpdated": "{0} stavki ažurirano", "MessageJoinUsOn": "Pridruži nam se na", "MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini", "MessageLoading": "Učitavam...", - "MessageLoadingFolders": "Učitavam foldere...", - "MessageLogsDescription": "Logs are stored in /metadata/logs as JSON files. Crash logs are stored in /metadata/logs/crash_logs.txt.", + "MessageLoadingFolders": "Učitavam mape...", + "MessageLogsDescription": "Zapisnici se čuvaju u /metadata/logs u obliku JSON datoteka. Zapisnici pada sustava čuvaju se u datoteci /metadata/logs/crash_logs.txt.", "MessageM4BFailed": "M4B neuspješan!", "MessageM4BFinished": "M4B završio!", - "MessageMapChapterTitles": "Mapiraj imena poglavlja u postoječa poglavlja bez izmijene timestampova.", - "MessageMarkAllEpisodesFinished": "Mark all episodes finished", - "MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished", + "MessageMapChapterTitles": "Mapiraj nazive poglavlja postojećim poglavljima zvučne knjige bez uređivanja vremenskih identifikatora", + "MessageMarkAllEpisodesFinished": "Označi sve nastavke dovršenima", + "MessageMarkAllEpisodesNotFinished": "Označi sve nastavke nedovršenima", "MessageMarkAsFinished": "Označi kao dovršeno", - "MessageMarkAsNotFinished": "Označi kao nezavršeno", - "MessageMatchBooksDescription": "će probati matchati knjige iz biblioteke sa knjigom od odabranog poslužitelja i popuniti prazne detalje i cover. Ne briše postojeće detalje.", - "MessageNoAudioTracks": "Nema audio tracks", + "MessageMarkAsNotFinished": "Označi kao nedovršeno", + "MessageMatchBooksDescription": "će probati prepoznati knjige iz knjižnice u katalogu odabranog pružatelja podatka te nadopuniti podatke koji nedostaju i naslovnice. Ne prepisuje preko postojećih podataka.", + "MessageNoAudioTracks": "Nema zvučnih zapisa", "MessageNoAuthors": "Nema autora", - "MessageNoBackups": "Nema backupa", + "MessageNoBackups": "Nema sigurnosnih kopija", "MessageNoBookmarks": "Nema knjižnih oznaka", "MessageNoChapters": "Nema poglavlja", - "MessageNoCollections": "Nema kolekcija", - "MessageNoCoversFound": "Covers nisu pronađeni", + "MessageNoCollections": "Nema zbirki", + "MessageNoCoversFound": "Naslovnice nisu pronađene", "MessageNoDescription": "Nema opisa", - "MessageNoDownloadsInProgress": "No downloads currently in progress", - "MessageNoDownloadsQueued": "No downloads queued", - "MessageNoEpisodeMatchesFound": "Nijedna epizoda pronađena", - "MessageNoEpisodes": "Nema epizoda", - "MessageNoFoldersAvailable": "Nema dostupnih foldera", + "MessageNoDevices": "Nema uređaja", + "MessageNoDownloadsInProgress": "Nema preuzimanja u tijeku", + "MessageNoDownloadsQueued": "Nema preuzimanja u redu", + "MessageNoEpisodeMatchesFound": "Nije pronađen ni jedan odgovarajući nastavak", + "MessageNoEpisodes": "Nema nastavaka", + "MessageNoFoldersAvailable": "Nema dostupnih mapa", "MessageNoGenres": "Nema žanrova", - "MessageNoIssues": "No Issues", + "MessageNoIssues": "Nema problema", "MessageNoItems": "Nema stavki", "MessageNoItemsFound": "Nema pronađenih stavki", "MessageNoListeningSessions": "Nema sesija slušanja", - "MessageNoLogs": "Nema Logs", - "MessageNoMediaProgress": "Nema Media napredka", + "MessageNoLogs": "Nema zapisnika", + "MessageNoMediaProgress": "Nema podataka o započetim medijima", "MessageNoNotifications": "Nema obavijesti", - "MessageNoPodcastsFound": "Podcasti nisu pronađeni", + "MessageNoPodcastsFound": "Nije pronađen niti jedan podcast", "MessageNoResults": "Nema rezultata", - "MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"", - "MessageNoSeries": "No Series", - "MessageNoTags": "No Tags", - "MessageNoTasksRunning": "No Tasks Running", - "MessageNoUpdateNecessary": "Aktualiziranje nije potrebno", + "MessageNoSearchResultsFor": "Nema rezultata pretrage za \"{0}\"", + "MessageNoSeries": "Nema serijala", + "MessageNoTags": "Nema oznaka", + "MessageNoTasksRunning": "Nema zadataka koji se izvode", "MessageNoUpdatesWereNecessary": "Ažuriranje nije bilo potrebno", "MessageNoUserPlaylists": "Nemate popisa za izvođenje", - "MessageNotYetImplemented": "Not yet implemented", - "MessageOr": "or", - "MessagePauseChapter": "Pause chapter playback", - "MessagePlayChapter": "Listen to beginning of chapter", - "MessagePlaylistCreateFromCollection": "Create playlist from collection", - "MessagePodcastHasNoRSSFeedForMatching": "Podcast nema RSS feed url za matchanje", - "MessageQuickMatchDescription": "Popuni prazne detalje stavki i cover sa prvim match rezultato iz '{0}'. Ne briše detalje osim ako 'Prefer matched metadata' server postavka nije uključena.", - "MessageRemoveChapter": "Remove chapter", - "MessageRemoveEpisodes": "ukloni {0} epizoda/-e", - "MessageRemoveFromPlayerQueue": "Remove from player queue", - "MessageRemoveUserWarning": "Jeste li sigurni da želite trajno obrisati korisnika \"{0}\"?", - "MessageReportBugsAndContribute": "Prijavite pogreške, zatražite osobine i doprinosite na adresi", - "MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?", - "MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran", - "MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.

Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.

Svi klijenti koji koriste tvoj server će biti automatski osvježeni.", - "MessageSearchResultsFor": "Traži rezultate za", - "MessageSelected": "{0} selected", - "MessageServerCouldNotBeReached": "Server ne može biti kontaktiran", - "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", + "MessageNotYetImplemented": "Još nije implementirano", + "MessageOpmlPreviewNote": "Napomena: Ovo je pretpregled raščlanjene OPML datoteke. Stvarni naslov podcasta preuzet će se iz RSS izvora.", + "MessageOr": "ili", + "MessagePauseChapter": "Pauziraj reprodukciju poglavlja", + "MessagePlayChapter": "Slušaj početak poglavlja", + "MessagePlaylistCreateFromCollection": "Stvori popis za izvođenje od zbirke", + "MessagePleaseWait": "Molimo pričekajte...", + "MessagePodcastHasNoRSSFeedForMatching": "Podcast nema adresu RSS izvora za prepoznavanje", + "MessageQuickMatchDescription": "Popuni pojedinosti i naslovnice koji nedostaju prvim pronađenim rezultatom za '{0}'. Ne prepisuje podatke osim ako ne uključite mogućnost 'Daj prednost meta-podatcima prepoznatih stavki'.", + "MessageRemoveChapter": "Ukloni poglavlje", + "MessageRemoveEpisodes": "Ukloni {0} nastavaka", + "MessageRemoveFromPlayerQueue": "Ukloni iz redoslijeda izvođenja", + "MessageRemoveUserWarning": "Sigurno želite trajno obrisati korisnika \"{0}\"?", + "MessageReportBugsAndContribute": "Prijavite pogreške, zatražite funkcionalnosti i doprinesite na", + "MessageResetChaptersConfirm": "Sigurno želite vratiti poglavlja na prethodno stanje i poništiti učinjene promjene?", + "MessageRestoreBackupConfirm": "Sigurno želite vratiti sigurnosnu kopiju izrađenu", + "MessageRestoreBackupWarning": "Vraćanjem sigurnosne kopije prepisat ćete cijelu bazu podataka koja se nalazi u /config i slike naslovnice u /metadata/items i /metadata/authors.

Sigurnosne kopije ne mijenjaju datoteke koje se nalaze u mapama vaših knjižnica. Ako ste u postavkama poslužitelja uključili mogućnost spremanja naslovnica i meta-podataka u mape knjižnice, te se datoteke neće niti sigurnosno pohraniti niti prepisati.

Svi klijenti koji se spajaju na vaš poslužitelj automatski će se osvježiti.", + "MessageSearchResultsFor": "Rezultati pretrage za", + "MessageSelected": "{0} odabrano", + "MessageServerCouldNotBeReached": "Nije moguće pristupiti poslužitelju", + "MessageSetChaptersFromTracksDescription": "Postavi poglavlja koristeći se zvučnom datotekom kao poglavljem i nazivom datoteke kao naslovom poglavlja", + "MessageShareExpirationWillBe": "Vrijeme isteka će biti {0}", + "MessageShareExpiresIn": "Istječe za {0}", + "MessageShareURLWillBe": "URL za dijeljenje bit će {0}", "MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?", "MessageThinking": "Razmišljam...", - "MessageUploaderItemFailed": "Upload neuspješan", - "MessageUploaderItemSuccess": "Upload uspješan!", - "MessageUploading": "Uploadam...", - "MessageValidCronExpression": "Ispravan cron expression", - "MessageWatcherIsDisabledGlobally": "Watcher je globalno isključen u postavkama servera", - "MessageXLibraryIsEmpty": "{0} Library is empty!", - "MessageYourAudiobookDurationIsLonger": "Trajanje audio knjige je duže nego pronadeđna duljina trajanja", - "MessageYourAudiobookDurationIsShorter": "Trajanje audio knjige je kraća nego pronadeđna duljina trajanja", - "NoteChangeRootPassword": "Root korisnik je jedini korisnik koji može imati praznu lozinku", - "NoteChapterEditorTimes": "Bilješka: Prvo početno vrijeme poglavlja mora ostati na 0:00 i posljednje vrijeme poglavlja ne smije preći vrijeme trajanja ove audio knjige.", - "NoteFolderPicker": "Bilješka: več mapirani folderi neće biti prikazani", - "NoteRSSFeedPodcastAppsHttps": "Upozorenje: Za većinu podcasta trebat će vam RSS izvor koji se koristi HTTPS-om. feed URL koji koristi HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "Upozorenje: 1 ili više vaših nastavaka nemaju datum objavljivanja. To je obavezno kod nekih aplikacija za podcaste.", - "NoteUploaderFoldersWithMediaFiles": "Folderi sa media datotekama će biti tretirane kao odvojene stavke u biblioteki.", - "NoteUploaderOnlyAudioFiles": "Ako uploadate samo audio datoteke onda će audio datoteka biti tretirana kao odvojena audioknjiga.", - "NoteUploaderUnsupportedFiles": "Nepodržane datoteke su ignorirane. Kada birate ili ubacujete folder, ostale datoteke koje nisu folder će biti ignorirane.", - "PlaceholderNewCollection": "Ime nove kolekcije", - "PlaceholderNewFolderPath": "Nova folder putanja", - "PlaceholderNewPlaylist": "New playlist name", + "MessageUploaderItemFailed": "Učitavanje nije uspjelo", + "MessageUploaderItemSuccess": "Uspješno učitano!", + "MessageUploading": "Učitavam...", + "MessageValidCronExpression": "Ispravan cron izraz", + "MessageWatcherIsDisabledGlobally": "Praćenje datotečnog sustava globalno je isključen u postavkama poslužitelja", + "MessageXLibraryIsEmpty": "{0} Knjižnica je prazna!", + "MessageYourAudiobookDurationIsLonger": "Vaše trajanje zvučne knjige duže je od pronađenog trajanja", + "MessageYourAudiobookDurationIsShorter": "Vaše trajanje zvučne knjige kraće je od pronađenog trajanja", + "NoteChangeRootPassword": "Samo root korisnik može imati praznu zaporku", + "NoteChapterEditorTimes": "Napomena: Vrijeme početka prvog poglavlja mora ostati 0:00, a vrijeme početka zadnjeg poglavlja ne može premašiti ukupno trajanje ove zvučne knjige.", + "NoteFolderPicker": "Napomena: mape koje su već mapirane neće se prikazati", + "NoteRSSFeedPodcastAppsHttps": "Pripazite: Većina aplikacija za podcaste iziskuje URL RSS izvora koji se koristi HTTPS protokolom", + "NoteRSSFeedPodcastAppsPubDate": "Upozorenje: jedan ili više vaših nastavaka nemaju datum objavljivanja. To je obavezno kod nekih aplikacija za podcaste.", + "NoteUploaderFoldersWithMediaFiles": "Mape s medijskim datotekama smatrat će se zasebnim stavkama knjižnice.", + "NoteUploaderOnlyAudioFiles": "Ako učitavate samo zvučne datoteke svaka će se zvučna datoteka uvesti kao zasebna zvučna knjiga.", + "NoteUploaderUnsupportedFiles": "Nepodržane vrste datoteka zanemaruju se. Kada odabirete datoteke ili ispuštate mapu, sve datoteke koje nisu u mapi stavke zanemarit će se.", + "PlaceholderNewCollection": "Ime nove zbirke", + "PlaceholderNewFolderPath": "Nova putanja mape", + "PlaceholderNewPlaylist": "Naziv novog popisa za izvođenje", "PlaceholderSearch": "Traži...", - "PlaceholderSearchEpisode": "Search episode...", - "ToastAccountUpdateFailed": "Neuspješno aktualiziranje korisničkog računa", - "ToastAccountUpdateSuccess": "Korisnički račun aktualiziran", - "ToastAuthorImageRemoveFailed": "Neuspješno uklanjanje slike", + "PlaceholderSearchEpisode": "Traži nastavak...", + "StatsAuthorsAdded": "autora dodano", + "StatsBooksAdded": "knjiga dodano", + "StatsBooksAdditional": "Novi naslovi uključuju…", + "StatsBooksFinished": "knjiga dovršeno", + "StatsBooksFinishedThisYear": "Neke knjige dovršene ove godine…", + "StatsBooksListenedTo": "knjiga slušano", + "StatsCollectionGrewTo": "Vaša zbirka knjiga narasla je na…", + "StatsSessions": "sesija", + "StatsSpentListening": "provedeno u slušanju", + "StatsTopAuthor": "NAJPOPULARNIJI AUTOR", + "StatsTopAuthors": "NAJPOPULARNIJI AUTORI", + "StatsTopGenre": "NAJPOPULARNIJI ŽANR", + "StatsTopGenres": "NAJPOPULARNIJI ŽANROVI", + "StatsTopMonth": "NAJJAČI MJESEC", + "StatsTopNarrator": "NAJPOPULARNIJI PRIPOVJEDAČ", + "StatsTopNarrators": "NAJPOPULARNIJI PRIPOVJEDAČI", + "StatsTotalDuration": "S ukupnim trajanjem od…", + "StatsYearInReview": "PREGLED GODINE", + "ToastAccountUpdateFailed": "Ažuriranje računa nije uspjelo", + "ToastAccountUpdateSuccess": "Račun ažuriran", + "ToastAppriseUrlRequired": "Obavezno upisati Apprise URL", "ToastAuthorImageRemoveSuccess": "Slika autora uklonjena", - "ToastAuthorUpdateFailed": "Neuspješno aktualiziranje autora", - "ToastAuthorUpdateMerged": "Autor spojen", - "ToastAuthorUpdateSuccess": "Autor aktualiziran ", - "ToastAuthorUpdateSuccessNoImageFound": "Autor aktualiziran (slika nije pronađena)", + "ToastAuthorNotFound": "Autor \"{0}\" nije pronađen", + "ToastAuthorRemoveSuccess": "Autor uklonjen", + "ToastAuthorSearchNotFound": "Autor nije pronađen", + "ToastAuthorUpdateFailed": "Ažuriranje autora nije uspjelo", + "ToastAuthorUpdateMerged": "Autor pripojen", + "ToastAuthorUpdateSuccess": "Autor ažuriran", + "ToastAuthorUpdateSuccessNoImageFound": "Autor ažuriran (slika nije pronađena)", + "ToastBackupAppliedSuccess": "Sigurnosna kopija vraćena", "ToastBackupCreateFailed": "Neuspješno kreiranje backupa", - "ToastBackupCreateSuccess": "Backup kreiran", - "ToastBackupDeleteFailed": "Neuspješno brisanje backupa", - "ToastBackupDeleteSuccess": "Backup obrisan", - "ToastBackupRestoreFailed": "Povračanje backupa neuspješno", - "ToastBackupUploadFailed": "Uploadanje backupa neuspješno", - "ToastBackupUploadSuccess": "Backup uploadan", - "ToastBatchUpdateFailed": "Batch update neuspješan", - "ToastBatchUpdateSuccess": "Batch update uspješan", + "ToastBackupCreateSuccess": "Izrađena sigurnosna kopija", + "ToastBackupDeleteFailed": "Brisanje sigurnosne kopije nije uspjelo", + "ToastBackupDeleteSuccess": "Sigurnosna kopija izbrisana", + "ToastBackupInvalidMaxKeep": "Neispravan broj sigurnosnih kopija za čuvanje", + "ToastBackupInvalidMaxSize": "Neispravna najveća veličina sigurnosne kopije", + "ToastBackupPathUpdateFailed": "Ažuriranje putanje za sigurnosne kopije nije uspjelo", + "ToastBackupRestoreFailed": "Vraćanje sigurnosne kopije nije uspjelo", + "ToastBackupUploadFailed": "Učitavanje sigurnosne kopije nije uspjelo", + "ToastBackupUploadSuccess": "Sigurnosna kopija učitana", + "ToastBatchDeleteFailed": "Grupno brisanje nije uspjelo", + "ToastBatchDeleteSuccess": "Grupno brisanje je uspješno dovršeno", + "ToastBatchUpdateFailed": "Skupno ažuriranje nije uspjelo", + "ToastBatchUpdateSuccess": "Skupno ažuriranje uspješno dovršeno", "ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela", - "ToastBookmarkCreateSuccess": "Knjižna bilješka dodana", - "ToastBookmarkRemoveFailed": "Brisanje knjižne bilješke nije uspjelo", - "ToastBookmarkRemoveSuccess": "Knjižnja bilješka uklonjena", - "ToastBookmarkUpdateFailed": "Ažuriranje knjižne bilješke nije uspjelo", - "ToastBookmarkUpdateSuccess": "Knjižna bilješka aktualizirana", - "ToastCachePurgeFailed": "Failed to purge cache", - "ToastCachePurgeSuccess": "Cache purged successfully", - "ToastChaptersHaveErrors": "Chapters have errors", - "ToastChaptersMustHaveTitles": "Chapters must have titles", - "ToastCollectionItemsRemoveFailed": "Neuspješno brisanje stavke/-i iz kolekcije", - "ToastCollectionItemsRemoveSuccess": "Stavka/-e obrisane iz kolekcije", - "ToastCollectionRemoveFailed": "Brisanje kolekcije neuspješno", - "ToastCollectionRemoveSuccess": "Kolekcija obrisana", - "ToastCollectionUpdateFailed": "Aktualiziranje kolekcije neuspješno", - "ToastCollectionUpdateSuccess": "Kolekcija aktualizirana", - "ToastDeleteFileFailed": "Failed to delete file", - "ToastDeleteFileSuccess": "File deleted", - "ToastFailedToLoadData": "Failed to load data", - "ToastItemCoverUpdateFailed": "Aktualiziranje covera stavke neuspješna", - "ToastItemCoverUpdateSuccess": "Cover stavke aktualiziran", - "ToastItemDetailsUpdateFailed": "Aktualiziranje detalja stavke neuspješno", - "ToastItemDetailsUpdateSuccess": "Detalji stavke aktualizirani", - "ToastItemDetailsUpdateUnneeded": "Aktualiziranje detalja stavke nepotrebno", + "ToastBookmarkCreateSuccess": "Knjižna oznaka dodana", + "ToastBookmarkRemoveSuccess": "Knjižna oznaka uklonjena", + "ToastBookmarkUpdateFailed": "Ažuriranje knjižne oznake nije uspjelo", + "ToastBookmarkUpdateSuccess": "Knjižna oznaka ažurirana", + "ToastCachePurgeFailed": "Čišćenje predmemorije nije uspjelo", + "ToastCachePurgeSuccess": "Predmemorija uspješno očišćena", + "ToastChaptersHaveErrors": "Poglavlja imaju pogreške", + "ToastChaptersMustHaveTitles": "Poglavlja moraju imati naslove", + "ToastChaptersRemoved": "Poglavlja uklonjena", + "ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku", + "ToastCollectionItemsAddSuccess": "Uspješno dodavanje stavki u zbirku", + "ToastCollectionItemsRemoveSuccess": "Stavke izbrisane iz zbirke", + "ToastCollectionRemoveSuccess": "Zbirka izbrisana", + "ToastCollectionUpdateFailed": "Ažuriranje zbirke nije uspjelo", + "ToastCollectionUpdateSuccess": "Zbirka ažurirana", + "ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo", + "ToastDeleteFileFailed": "Brisanje datoteke nije uspjelo", + "ToastDeleteFileSuccess": "Datoteka izbrisana", + "ToastDeviceAddFailed": "Dodavanje uređaja nije uspjelo", + "ToastDeviceNameAlreadyExists": "E-čitač s tim nazivom već postoji", + "ToastDeviceTestEmailFailed": "Slanje probne poruke e-pošte nije uspjelo", + "ToastDeviceTestEmailSuccess": "Probna poruka e-pošte poslana", + "ToastDeviceUpdateFailed": "Ažuriranje uređaja nije uspjelo", + "ToastEmailSettingsUpdateFailed": "Ažuriranje postavki e-pošte nije uspjelo", + "ToastEmailSettingsUpdateSuccess": "Postavke e-pošte ažurirane", + "ToastEncodeCancelFailed": "Kodiranje nije uspješno otkazano", + "ToastEncodeCancelSucces": "Kodiranje otkazano", + "ToastEpisodeDownloadQueueClearFailed": "Redoslijed izvođenja nije uspješno očišćen", + "ToastEpisodeDownloadQueueClearSuccess": "Redoslijed preuzimanja nastavaka očišćen", + "ToastErrorCannotShare": "Dijeljenje na ovaj uređaj nije moguće", + "ToastFailedToLoadData": "Učitavanje podataka nije uspjelo", + "ToastFailedToShare": "Dijeljenje nije uspjelo", + "ToastFailedToUpdateAccount": "Ažuriranje računa nije uspjelo", + "ToastFailedToUpdateUser": "Ažuriranje korisnika nije uspjelo", + "ToastInvalidImageUrl": "Neispravan URL slike", + "ToastInvalidUrl": "Neispravan URL", + "ToastItemCoverUpdateFailed": "Ažuriranje naslovnice stavke nije uspjelo", + "ToastItemCoverUpdateSuccess": "Naslovnica stavke ažurirana", + "ToastItemDeletedFailed": "Brisanje stavke nije uspjelo", + "ToastItemDeletedSuccess": "Stavka je izbrisana", + "ToastItemDetailsUpdateFailed": "Ažuriranje podataka stavke nije uspjelo", + "ToastItemDetailsUpdateSuccess": "Pojedinosti stavke su ažurirane", "ToastItemMarkedAsFinishedFailed": "Označavanje kao Dovršeno nije uspjelo", - "ToastItemMarkedAsFinishedSuccess": "Stavka označena kao Završeno", + "ToastItemMarkedAsFinishedSuccess": "Stavka označena kao dovršena", "ToastItemMarkedAsNotFinishedFailed": "Označavanje kao Nije dovršeno nije uspjelo", - "ToastItemMarkedAsNotFinishedSuccess": "Stavka oznaečena kao Nezavršeno", - "ToastLibraryCreateFailed": "Kreiranje biblioteke neuspješno", - "ToastLibraryCreateSuccess": "Biblioteka \"{0}\" kreirana", - "ToastLibraryDeleteFailed": "Brisanje biblioteke neuspješno", - "ToastLibraryDeleteSuccess": "Biblioteka obrisana", - "ToastLibraryScanFailedToStart": "Skeniranje neuspješno", - "ToastLibraryScanStarted": "Sken biblioteke pokrenut", - "ToastLibraryUpdateFailed": "Aktualiziranje biblioteke neuspješno", - "ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" aktualizirana", + "ToastItemMarkedAsNotFinishedSuccess": "Stavka označena kao nedovršena", + "ToastItemUpdateFailed": "Ažuriranje stavke nije uspjelo", + "ToastItemUpdateSuccess": "Stavka ažurirana", + "ToastLibraryCreateFailed": "Stvaranje knjižnice nije uspjelo", + "ToastLibraryCreateSuccess": "Knjižnica \"{0}\" stvorena", + "ToastLibraryDeleteFailed": "Brisanje knjižnice nije uspjelo", + "ToastLibraryDeleteSuccess": "Knjižnica izbrisana", + "ToastLibraryScanFailedToStart": "Skeniranje nije uspjelo", + "ToastLibraryScanStarted": "Skeniranje knjižnice započelo", + "ToastLibraryUpdateFailed": "Ažuriranje knjižnice nije uspjelo", + "ToastLibraryUpdateSuccess": "Knjižnica \"{0}\" ažurirana", + "ToastNameEmailRequired": "Ime i adresa e-pošte su obavezni", + "ToastNameRequired": "Ime je obavezno", + "ToastNewUserCreatedFailed": "Račun \"{0}\" nije uspješno izrađen", + "ToastNewUserCreatedSuccess": "Novi račun izrađen", + "ToastNewUserLibraryError": "Treba odabrati barem jednu knjižnicu", + "ToastNewUserPasswordError": "Mora imati zaporku, samo korisnik root može imati praznu zaporku", + "ToastNewUserTagError": "Potrebno je odabrati najmanje jednu oznaku", + "ToastNewUserUsernameError": "Upišite korisničko ime", + "ToastNoUpdatesNecessary": "Ažuriranja nisu potrebna", + "ToastNotificationCreateFailed": "Stvaranje obavijesti nije uspjelo", + "ToastNotificationDeleteFailed": "Brisanje obavijesti nije uspjelo", + "ToastNotificationFailedMaximum": "Najveći broj neuspješnih pokušaja mora biti >= 0", + "ToastNotificationQueueMaximum": "Najveći broj obavijesti u redu mora biti >= 0", + "ToastNotificationSettingsUpdateFailed": "Ažuriranje postavki obavijesti nije uspjelo", + "ToastNotificationSettingsUpdateSuccess": "Postavke obavijesti ažurirane", + "ToastNotificationTestTriggerFailed": "Okidanje probne obavijesti nije uspjelo", + "ToastNotificationTestTriggerSuccess": "Okinuta je probna obavijest", + "ToastNotificationUpdateFailed": "Ažuriranje obavijesti nije uspjelo", + "ToastNotificationUpdateSuccess": "Obavijest ažurirana", "ToastPlaylistCreateFailed": "Popis za izvođenje nije izrađen", - "ToastPlaylistCreateSuccess": "Playlist created", - "ToastPlaylistRemoveFailed": "Failed to remove playlist", - "ToastPlaylistRemoveSuccess": "Playlist removed", - "ToastPlaylistUpdateFailed": "Failed to update playlist", - "ToastPlaylistUpdateSuccess": "Playlist updated", + "ToastPlaylistCreateSuccess": "Popis za izvođenje izrađen", + "ToastPlaylistRemoveSuccess": "Popis za izvođenje uklonjen", + "ToastPlaylistUpdateFailed": "Ažuriranje popisa za izvođenje nije uspjelo", + "ToastPlaylistUpdateSuccess": "Popis za izvođenje ažuriran", "ToastPodcastCreateFailed": "Podcast nije izrađen", "ToastPodcastCreateSuccess": "Podcast uspješno izrađen", + "ToastPodcastGetFeedFailed": "Dohvat izvora podcasta nije uspio", + "ToastPodcastNoEpisodesInFeed": "U RSS izvoru nisu pronađeni nastavci", + "ToastPodcastNoRssFeed": "Podcast nema RSS izvor", + "ToastProviderCreatedFailed": "Dodavanje pružatelja nije uspjelo", + "ToastProviderCreatedSuccess": "Novi pružatelj dodan", + "ToastProviderNameAndUrlRequired": "Ime i URL su obavezni", + "ToastProviderRemoveSuccess": "Pružatelj uklonjen", "ToastRSSFeedCloseFailed": "RSS izvor nije uspješno zatvoren", "ToastRSSFeedCloseSuccess": "RSS izvor zatvoren", - "ToastRemoveItemFromCollectionFailed": "Neuspješno uklanjanje stavke iz kolekcije", - "ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz kolekcije", - "ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device", - "ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"", - "ToastSeriesUpdateFailed": "Series update failed", - "ToastSeriesUpdateSuccess": "Series update success", - "ToastServerSettingsUpdateFailed": "Failed to update server settings", - "ToastServerSettingsUpdateSuccess": "Server settings updated", + "ToastRemoveFailed": "Uklanjanje nije uspjelo", + "ToastRemoveItemFromCollectionFailed": "Neuspješno uklanjanje stavke iz zbirke", + "ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz zbirke", + "ToastRemoveItemsWithIssuesFailed": "Uklanjanje knjižničkih stavki s problemima nije uspjelo", + "ToastRemoveItemsWithIssuesSuccess": "Uspješno uklonjene knjižničke stavke s problemima", + "ToastRenameFailed": "Preimenovanje nije uspjelo", + "ToastRescanFailed": "Ponovno skeniranje {0} nije uspjelo", + "ToastRescanRemoved": "Ponovno skeniranje dovršene stavke je uklonjeno", + "ToastRescanUpToDate": "Ponovno skeniranje dovršene stavke bilo je ažurno", + "ToastRescanUpdated": "Ponovno skeniranje dovršene stavke je ažurirano", + "ToastScanFailed": "Skeniranje knjižničke stavke nije uspjelo", + "ToastSelectAtLeastOneUser": "Odaberite najmanje jednog korisnika", + "ToastSendEbookToDeviceFailed": "Slanje e-knjige na uređaj nije uspjelo", + "ToastSendEbookToDeviceSuccess": "E-knjiga poslana uređaju \"{0}\"", + "ToastSeriesUpdateFailed": "Ažuriranje serijala nije uspjelo", + "ToastSeriesUpdateSuccess": "Serijal uspješno ažuriran", + "ToastServerSettingsUpdateFailed": "Ažuriranje postavki poslužitelja nije uspjelo", + "ToastServerSettingsUpdateSuccess": "Postavke poslužitelja ažurirane", + "ToastSessionCloseFailed": "Zatvaranje sesije nije uspjelo", "ToastSessionDeleteFailed": "Neuspješno brisanje serije", "ToastSessionDeleteSuccess": "Sesija obrisana", - "ToastSocketConnected": "Socket connected", - "ToastSocketDisconnected": "Socket disconnected", - "ToastSocketFailedToConnect": "Socket failed to connect", - "ToastSortingPrefixesEmptyError": "Must have at least 1 sorting prefix", - "ToastSortingPrefixesUpdateFailed": "Failed to update sorting prefixes", - "ToastSortingPrefixesUpdateSuccess": "Sorting prefixes updated ({0} items)", + "ToastSlugMustChange": "Slug sadrži nedozvoljene znakove", + "ToastSlugRequired": "Slug je obavezan", + "ToastSocketConnected": "Socket priključen", + "ToastSocketDisconnected": "Veza sa socketom je prekinuta", + "ToastSocketFailedToConnect": "Priključivanje na socket nije uspjelo", + "ToastSortingPrefixesEmptyError": "Mora imati najmanje jedan prefiks za sortiranje", + "ToastSortingPrefixesUpdateFailed": "Ažuriranje prefiksa za sortiranje nije uspjelo", + "ToastSortingPrefixesUpdateSuccess": "Prefiksi za sortiranje ažurirani ({0} stavki)", + "ToastTitleRequired": "Naslov je obavezan", + "ToastUnknownError": "Nepoznata pogreška", + "ToastUnlinkOpenIdFailed": "Uklanjanje OpenID veze korisnika nije uspjelo", + "ToastUnlinkOpenIdSuccess": "Korisnik odspojen od OpenID-ja", "ToastUserDeleteFailed": "Neuspješno brisanje korisnika", - "ToastUserDeleteSuccess": "Korisnik obrisan" + "ToastUserDeleteSuccess": "Korisnik obrisan", + "ToastUserPasswordChangeSuccess": "Zaporka je uspješno promijenjena", + "ToastUserPasswordMismatch": "Zaporke se ne podudaraju", + "ToastUserPasswordMustChange": "Nova zaporka ne smije biti jednaka staroj", + "ToastUserRootRequireName": "Obavezan je unos korisničkog imena root korisnika" } diff --git a/client/strings/hu.json b/client/strings/hu.json index cc12227a59..15e267f3d9 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "{0} könyv hozzáadása a gyűjteményhez", "LabelAddToPlaylist": "Hozzáadás a lejátszási listához", "LabelAddToPlaylistBatch": "{0} elem hozzáadása a lejátszási listához", - "LabelAdded": "Hozzáadva", "LabelAddedAt": "Hozzáadás ideje", "LabelAdminUsersOnly": "Csak admin felhasználók", "LabelAll": "Minden", @@ -692,7 +691,6 @@ "MessageNoSeries": "Nincsenek sorozatok", "MessageNoTags": "Nincsenek címkék", "MessageNoTasksRunning": "Nincsenek futó feladatok", - "MessageNoUpdateNecessary": "Nincs szükség frissítésre", "MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre", "MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák", "MessageNotYetImplemented": "Még nem implementált", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Epizód keresése..", "ToastAccountUpdateFailed": "A fiók frissítése sikertelen", "ToastAccountUpdateSuccess": "Fiók frissítve", - "ToastAuthorImageRemoveFailed": "A kép eltávolítása sikertelen", "ToastAuthorImageRemoveSuccess": "Szerző képe eltávolítva", "ToastAuthorUpdateFailed": "A szerző frissítése sikertelen", "ToastAuthorUpdateMerged": "Szerző összevonva", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres", "ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen", "ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva", - "ToastBookmarkRemoveFailed": "Könyvjelző eltávolítása sikertelen", "ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva", "ToastBookmarkUpdateFailed": "Könyvjelző frissítése sikertelen", "ToastBookmarkUpdateSuccess": "Könyvjelző frissítve", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak", "ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük", - "ToastCollectionItemsRemoveFailed": "Elem(ek) eltávolítása a gyűjteményből sikertelen", "ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből", - "ToastCollectionRemoveFailed": "Gyűjtemény eltávolítása sikertelen", "ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva", "ToastCollectionUpdateFailed": "Gyűjtemény frissítése sikertelen", "ToastCollectionUpdateSuccess": "Gyűjtemény frissítve", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Elem borítója frissítve", "ToastItemDetailsUpdateFailed": "Elem részleteinek frissítése sikertelen", "ToastItemDetailsUpdateSuccess": "Elem részletei frissítve", - "ToastItemDetailsUpdateUnneeded": "Nincsenek szükséges frissítések a tétel részletein", "ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen", "ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként", "ToastItemMarkedAsNotFinishedFailed": "Nem sikerült Nem Befejezettként megjelölni az elemet", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "\"{0}\" könyvtár frissítve", "ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen", "ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva", - "ToastPlaylistRemoveFailed": "Lejátszási lista eltávolítása sikertelen", "ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva", "ToastPlaylistUpdateFailed": "Lejátszási lista frissítése sikertelen", "ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve", diff --git a/client/strings/it.json b/client/strings/it.json index 66e4930e98..27e0d69b60 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -205,7 +205,6 @@ "LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta", "LabelAddToPlaylist": "Aggiungi alla playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist", - "LabelAdded": "Aggiunto", "LabelAddedAt": "Aggiunto il", "LabelAdminUsersOnly": "Solo utenti Amministratori", "LabelAll": "Tutti", @@ -722,7 +721,6 @@ "MessageNoSeries": "Nessuna Serie", "MessageNoTags": "Nessun Tags", "MessageNoTasksRunning": "Nessun processo in esecuzione", - "MessageNoUpdateNecessary": "Nessun aggiornamento necessario", "MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario", "MessageNoUserPlaylists": "non hai nessuna Playlist", "MessageNotYetImplemented": "Non Ancora Implementato", @@ -791,7 +789,6 @@ "StatsYearInReview": "ANNO IN RASSEGNA", "ToastAccountUpdateFailed": "Aggiornamento Account Fallito", "ToastAccountUpdateSuccess": "Account Aggiornato", - "ToastAuthorImageRemoveFailed": "Rimozione immagine autore Fallita", "ToastAuthorImageRemoveSuccess": "Immagine Autore Rimossa", "ToastAuthorUpdateFailed": "Aggiornamento Autore Fallito", "ToastAuthorUpdateMerged": "Autore unito", @@ -808,7 +805,6 @@ "ToastBatchUpdateSuccess": "Batch di aggiornamento finito", "ToastBookmarkCreateFailed": "Creazione segnalibro fallita", "ToastBookmarkCreateSuccess": "Segnalibro creato", - "ToastBookmarkRemoveFailed": "Rimozione segnalibro fallita", "ToastBookmarkRemoveSuccess": "Segnalibro Rimosso", "ToastBookmarkUpdateFailed": "Aggiornamento segnalibro fallito", "ToastBookmarkUpdateSuccess": "Segnalibro aggiornato", @@ -816,9 +812,7 @@ "ToastCachePurgeSuccess": "Cache eliminata correttamente", "ToastChaptersHaveErrors": "I capitoli contengono errori", "ToastChaptersMustHaveTitles": "I capitoli devono avere titoli", - "ToastCollectionItemsRemoveFailed": "Rimozione oggetti dalla Raccolta fallita", "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta", - "ToastCollectionRemoveFailed": "Rimozione Raccolta fallita", "ToastCollectionRemoveSuccess": "Collezione rimossa", "ToastCollectionUpdateFailed": "Errore aggiornamento Raccolta", "ToastCollectionUpdateSuccess": "Raccolta aggiornata", @@ -830,7 +824,6 @@ "ToastItemCoverUpdateSuccess": "Cover aggiornata", "ToastItemDetailsUpdateFailed": "Errore Aggiornamento dettagli file", "ToastItemDetailsUpdateSuccess": "Dettagli file Aggiornata", - "ToastItemDetailsUpdateUnneeded": "Nessun Aggiornamento necessario per il file", "ToastItemMarkedAsFinishedFailed": "Errore nel segnare il file come finito", "ToastItemMarkedAsFinishedSuccess": "File segnato come finito", "ToastItemMarkedAsNotFinishedFailed": "Errore nel segnare il file come non completo", @@ -845,7 +838,6 @@ "ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata", "ToastPlaylistCreateFailed": "Errore creazione playlist", "ToastPlaylistCreateSuccess": "Playlist creata", - "ToastPlaylistRemoveFailed": "Rimozione Playlist Fallita", "ToastPlaylistRemoveSuccess": "Playlist rimossa", "ToastPlaylistUpdateFailed": "Aggiornamento Playlist Fallita", "ToastPlaylistUpdateSuccess": "Playlist Aggiornata", diff --git a/client/strings/lt.json b/client/strings/lt.json index 2e064aff1a..2d749e80af 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Pridėti {0} knygas į kolekciją", "LabelAddToPlaylist": "Pridėti į grojaraštį", "LabelAddToPlaylistBatch": "Pridėti {0} elementus į grojaraštį", - "LabelAdded": "Pridėta", "LabelAddedAt": "Pridėta {0}", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Visi", @@ -692,7 +691,6 @@ "MessageNoSeries": "Serijų nėra", "MessageNoTags": "Žymų nėra", "MessageNoTasksRunning": "Nėra vykstančių užduočių", - "MessageNoUpdateNecessary": "Atnaujinimai nereikalingi", "MessageNoUpdatesWereNecessary": "Nereikalingi jokie atnaujinimai", "MessageNoUserPlaylists": "Neturite grojaraščių", "MessageNotYetImplemented": "Dar neįgyvendinta", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Ieškoti epizodo..", "ToastAccountUpdateFailed": "Paskyros atnaujinimas nepavyko", "ToastAccountUpdateSuccess": "Paskyra atnaujinta", - "ToastAuthorImageRemoveFailed": "Nepavyko pašalinti autoriaus paveiksliuko", "ToastAuthorImageRemoveSuccess": "Autoriaus paveiksliukas pašalintas", "ToastAuthorUpdateFailed": "Nepavyko atnaujinti autoriaus", "ToastAuthorUpdateMerged": "Autorius sujungtas", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Masinis atnaujinimas sėkmingas", "ToastBookmarkCreateFailed": "Žymos sukurti nepavyko", "ToastBookmarkCreateSuccess": "Žyma pridėta", - "ToastBookmarkRemoveFailed": "Žymos pašalinti nepavyko", "ToastBookmarkRemoveSuccess": "Žyma pašalinta", "ToastBookmarkUpdateFailed": "Žymos atnaujinti nepavyko", "ToastBookmarkUpdateSuccess": "Žyma atnaujinta", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Skyriai turi klaidų", "ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus", - "ToastCollectionItemsRemoveFailed": "Elementų pašalinti iš kolekcijos nepavyko", "ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos", - "ToastCollectionRemoveFailed": "Kolekcijos pašalinti nepavyko", "ToastCollectionRemoveSuccess": "Kolekcija pašalinta", "ToastCollectionUpdateFailed": "Kolekcijos atnaujinti nepavyko", "ToastCollectionUpdateSuccess": "Kolekcija atnaujinta", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Elemento viršelis atnaujintas", "ToastItemDetailsUpdateFailed": "Elemento detalių atnaujinti nepavyko", "ToastItemDetailsUpdateSuccess": "Elemento detalės atnaujintos", - "ToastItemDetailsUpdateUnneeded": "Elemento detalės atnaujinimas nereikalingas", "ToastItemMarkedAsFinishedFailed": "Pažymėti kaip Baigta nepavyko", "ToastItemMarkedAsFinishedSuccess": "Elementas pažymėtas kaip Baigta", "ToastItemMarkedAsNotFinishedFailed": "Pažymėti kaip Nebaigta nepavyko", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" atnaujinta", "ToastPlaylistCreateFailed": "Grojaraščio sukurti nepavyko", "ToastPlaylistCreateSuccess": "Grojaraštis sukurtas", - "ToastPlaylistRemoveFailed": "Grojaraščio pašalinti nepavyko", "ToastPlaylistRemoveSuccess": "Grojaraštis pašalintas", "ToastPlaylistUpdateFailed": "Grojaraščio atnaujinti nepavyko", "ToastPlaylistUpdateSuccess": "Grojaraštis atnaujintas", diff --git a/client/strings/nl.json b/client/strings/nl.json index e209c3a508..0d9a65b910 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "{0} boeken toevoegen aan collectie", "LabelAddToPlaylist": "Toevoegen aan afspeellijst", "LabelAddToPlaylistBatch": "{0} onderdelen toevoegen aan afspeellijst", - "LabelAdded": "Toegevoegd", "LabelAddedAt": "Toegevoegd op", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", @@ -694,7 +693,6 @@ "MessageNoSeries": "Geen series", "MessageNoTags": "Geen tags", "MessageNoTasksRunning": "Geen lopende taken", - "MessageNoUpdateNecessary": "Geen bijwerking noodzakelijk", "MessageNoUpdatesWereNecessary": "Geen bijwerkingen waren noodzakelijk", "MessageNoUserPlaylists": "Je hebt geen afspeellijsten", "MessageNotYetImplemented": "Nog niet geimplementeerd", @@ -742,7 +740,6 @@ "PlaceholderSearchEpisode": "Aflevering zoeken..", "ToastAccountUpdateFailed": "Bijwerken account mislukt", "ToastAccountUpdateSuccess": "Account bijgewerkt", - "ToastAuthorImageRemoveFailed": "Afbeelding verwijderen mislukt", "ToastAuthorImageRemoveSuccess": "Afbeelding auteur verwijderd", "ToastAuthorUpdateFailed": "Bijwerken auteur mislukt", "ToastAuthorUpdateMerged": "Auteur samengevoegd", @@ -759,7 +756,6 @@ "ToastBatchUpdateSuccess": "Bulk-bijwerking gelukt", "ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt", "ToastBookmarkCreateSuccess": "boekwijzer toegevoegd", - "ToastBookmarkRemoveFailed": "Verwijderen boekwijzer mislukt", "ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd", "ToastBookmarkUpdateFailed": "Bijwerken boekwijzer mislukt", "ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt", @@ -767,9 +763,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten", "ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben", - "ToastCollectionItemsRemoveFailed": "Verwijderen onderdeel (of onderdelen) uit collectie mislukt", "ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie", - "ToastCollectionRemoveFailed": "Verwijderen collectie mislukt", "ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionUpdateFailed": "Bijwerken collectie mislukt", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt", @@ -780,7 +774,6 @@ "ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt", "ToastItemDetailsUpdateFailed": "Bijwerken details onderdeel mislukt", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt", - "ToastItemDetailsUpdateUnneeded": "Geen bijwerking nodig voor details onderdeel", "ToastItemMarkedAsFinishedFailed": "Markeren als Voltooid mislukt", "ToastItemMarkedAsFinishedSuccess": "Onderdeel gemarkeerd als Voltooid", "ToastItemMarkedAsNotFinishedFailed": "Markeren als Niet Voltooid mislukt", @@ -795,7 +788,6 @@ "ToastLibraryUpdateSuccess": "Bibliotheek \"{0}\" bijgewerkt", "ToastPlaylistCreateFailed": "Aanmaken afspeellijst mislukt", "ToastPlaylistCreateSuccess": "Afspeellijst aangemaakt", - "ToastPlaylistRemoveFailed": "Verwijderen afspeellijst mislukt", "ToastPlaylistRemoveSuccess": "Afspeellijst verwijderd", "ToastPlaylistUpdateFailed": "Afspeellijst bijwerken mislukt", "ToastPlaylistUpdateSuccess": "Afspeellijst bijgewerkt", diff --git a/client/strings/no.json b/client/strings/no.json index e2a7dbcae2..2cfcad6128 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -204,7 +204,6 @@ "LabelAddToCollectionBatch": "Legg {0} bøker til samling", "LabelAddToPlaylist": "Legg til i spilleliste", "LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste", - "LabelAdded": "Lagt til", "LabelAddedAt": "Lagt Til", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", @@ -705,7 +704,6 @@ "MessageNoSeries": "Ingen serier", "MessageNoTags": "Ingen tags", "MessageNoTasksRunning": "Ingen oppgaver kjører", - "MessageNoUpdateNecessary": "Ingen oppdatering nødvendig", "MessageNoUpdatesWereNecessary": "Ingen oppdatering var nødvendig", "MessageNoUserPlaylists": "Du har ingen spillelister", "MessageNotYetImplemented": "Ikke implementert ennå", @@ -752,7 +750,6 @@ "PlaceholderSearchEpisode": "Søk episode..", "ToastAccountUpdateFailed": "Mislykkes å oppdatere konto", "ToastAccountUpdateSuccess": "Konto oppdatert", - "ToastAuthorImageRemoveFailed": "Mislykkes å fjerne bilde", "ToastAuthorImageRemoveSuccess": "Forfatter bilde fjernet", "ToastAuthorUpdateFailed": "Mislykkes å oppdatere forfatter", "ToastAuthorUpdateMerged": "Forfatter slått sammen", @@ -769,7 +766,6 @@ "ToastBatchUpdateSuccess": "Bulk oppdatering fullført", "ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke", "ToastBookmarkCreateSuccess": "Bokmerke lagt til", - "ToastBookmarkRemoveFailed": "Misslykkes å fjerne bokmerke", "ToastBookmarkRemoveSuccess": "Bokmerke fjernet", "ToastBookmarkUpdateFailed": "Misslykkes å oppdatere bokmerke", "ToastBookmarkUpdateSuccess": "Bokmerke oppdatert", @@ -777,9 +773,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Kapittel har feil", "ToastChaptersMustHaveTitles": "Kapittel må ha titler", - "ToastCollectionItemsRemoveFailed": "Misslykkes å fjerne gjenstand(er) fra samling", "ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling", - "ToastCollectionRemoveFailed": "Misslykkes å fjerne samling", "ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionUpdateFailed": "Misslykkes å oppdatere samling", "ToastCollectionUpdateSuccess": "samlingupdated", @@ -790,7 +784,6 @@ "ToastItemCoverUpdateSuccess": "Omslag oppdatert", "ToastItemDetailsUpdateFailed": "Misslykkes å oppdatere detaljer", "ToastItemDetailsUpdateSuccess": "Detaljer oppdatert", - "ToastItemDetailsUpdateUnneeded": "Ingen oppdateringer nødvendig for detaljer", "ToastItemMarkedAsFinishedFailed": "Misslykkes å markere som Fullført", "ToastItemMarkedAsFinishedSuccess": "Gjenstand marker som Fullført", "ToastItemMarkedAsNotFinishedFailed": "Misslykkes å markere som Ikke Fullført", @@ -805,7 +798,6 @@ "ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" oppdatert", "ToastPlaylistCreateFailed": "Misslykkes å opprette spilleliste", "ToastPlaylistCreateSuccess": "Spilleliste opprettet", - "ToastPlaylistRemoveFailed": "Misslykkes å fjerne spilleliste", "ToastPlaylistRemoveSuccess": "Spilleliste fjernet", "ToastPlaylistUpdateFailed": "Misslykkes å oppdatere spilleliste", "ToastPlaylistUpdateSuccess": "Spilleliste oppdatert", diff --git a/client/strings/pl.json b/client/strings/pl.json index 4467431ba4..bb24cddce0 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -206,7 +206,6 @@ "LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji", "LabelAddToPlaylist": "Dodaj do playlisty", "LabelAddToPlaylistBatch": "Dodaj {0} pozycji do playlisty", - "LabelAdded": "Dodane", "LabelAddedAt": "Dodano", "LabelAdminUsersOnly": "Tylko użytkownicy administracyjni", "LabelAll": "Wszystkie", @@ -723,7 +722,6 @@ "MessageNoSeries": "No Series", "MessageNoTags": "No Tags", "MessageNoTasksRunning": "Brak uruchomionych zadań", - "MessageNoUpdateNecessary": "Brak konieczności aktualizacji", "MessageNoUpdatesWereNecessary": "Brak aktualizacji", "MessageNoUserPlaylists": "Nie masz żadnych list odtwarzania", "MessageNotYetImplemented": "Jeszcze nie zaimplementowane", @@ -790,7 +788,6 @@ "StatsYearInReview": "PRZEGLĄD ROKU", "ToastAccountUpdateFailed": "Nie udało się zaktualizować konta", "ToastAccountUpdateSuccess": "Zaktualizowano konto", - "ToastAuthorImageRemoveFailed": "Nie udało się usunąć obrazu", "ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte", "ToastAuthorUpdateFailed": "nie udało się zaktualizować autora", "ToastAuthorUpdateMerged": "Autor scalony", @@ -807,7 +804,6 @@ "ToastBatchUpdateSuccess": "Aktualizacja wsadowa powiodła się", "ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki", "ToastBookmarkCreateSuccess": "Dodano zakładkę", - "ToastBookmarkRemoveFailed": "Nie udało się usunąć zakładki", "ToastBookmarkRemoveSuccess": "Zakładka została usunięta", "ToastBookmarkUpdateFailed": "Nie udało się zaktualizować zakładki", "ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę", @@ -815,9 +811,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersMustHaveTitles": "Chapters must have titles", - "ToastCollectionItemsRemoveFailed": "Nie udało się usunąć pozycji z kolekcji", "ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji", - "ToastCollectionRemoveFailed": "Nie udało się usunąć kolekcji", "ToastCollectionRemoveSuccess": "Kolekcja usunięta", "ToastCollectionUpdateFailed": "Nie udało się zaktualizować kolekcji", "ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję", @@ -828,7 +822,6 @@ "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", "ToastItemDetailsUpdateFailed": "Nie udało się zaktualizować szczegółów", "ToastItemDetailsUpdateSuccess": "Zaktualizowano szczegóły", - "ToastItemDetailsUpdateUnneeded": "Brak aktulizacji dla pozycji", "ToastItemMarkedAsFinishedFailed": "Nie udało się oznaczyć jako ukończone", "ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona", "ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się", @@ -843,7 +836,6 @@ "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty", "ToastPlaylistCreateSuccess": "Playlista utworzona", - "ToastPlaylistRemoveFailed": "Nie udało się usunąć playlisty", "ToastPlaylistRemoveSuccess": "Playlista usunięta", "ToastPlaylistUpdateFailed": "Nie udało się zaktualizować playlisty", "ToastPlaylistUpdateSuccess": "Playlista zaktualizowana", diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index 027e511250..163bb461b2 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Adicionar {0} Livros à Coleção", "LabelAddToPlaylist": "Adicionar à Lista de Reprodução", "LabelAddToPlaylistBatch": "Adicionar {0} itens à Lista de Reprodução", - "LabelAdded": "Acrescentado", "LabelAddedAt": "Acrescentado em", "LabelAdminUsersOnly": "Apenas usuários administradores", "LabelAll": "Todos", @@ -692,7 +691,6 @@ "MessageNoSeries": "Sem Séries", "MessageNoTags": "Sem etiquetas", "MessageNoTasksRunning": "Sem Tarefas em Execução", - "MessageNoUpdateNecessary": "Não é necessária a atualização", "MessageNoUpdatesWereNecessary": "Nenhuma atualização é necessária", "MessageNoUserPlaylists": "Você não tem listas de reprodução", "MessageNotYetImplemented": "Ainda não implementado", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Buscar Episódio..", "ToastAccountUpdateFailed": "Falha ao atualizar a conta", "ToastAccountUpdateSuccess": "Conta atualizada", - "ToastAuthorImageRemoveFailed": "Falha ao remover imagem", "ToastAuthorImageRemoveSuccess": "Imagem do autor removida", "ToastAuthorUpdateFailed": "Falha ao atualizar o autor", "ToastAuthorUpdateMerged": "Autor combinado", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Atualização em lote realizada", "ToastBookmarkCreateFailed": "Falha ao criar marcador", "ToastBookmarkCreateSuccess": "Marcador adicionado", - "ToastBookmarkRemoveFailed": "Falha ao remover marcador", "ToastBookmarkRemoveSuccess": "Marcador removido", "ToastBookmarkUpdateFailed": "Falha ao atualizar o marcador", "ToastBookmarkUpdateSuccess": "Marcador atualizado", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache apagado com sucesso", "ToastChaptersHaveErrors": "Capítulos com erro", "ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos", - "ToastCollectionItemsRemoveFailed": "Falha ao remover item(ns) da coleção", "ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção", - "ToastCollectionRemoveFailed": "Falha ao remover coleção", "ToastCollectionRemoveSuccess": "Coleção removida", "ToastCollectionUpdateFailed": "Falha ao atualizar coleção", "ToastCollectionUpdateSuccess": "Coleção atualizada", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Capa do item atualizada", "ToastItemDetailsUpdateFailed": "Falha ao atualizar detalhes do item", "ToastItemDetailsUpdateSuccess": "Detalhes do item atualizados", - "ToastItemDetailsUpdateUnneeded": "Nenhuma atualização necessária para os detalhes do item", "ToastItemMarkedAsFinishedFailed": "Falha ao marcar como Concluído", "ToastItemMarkedAsFinishedSuccess": "Item marcado como Concluído", "ToastItemMarkedAsNotFinishedFailed": "Falha ao marcar como Não Concluído", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" atualizada", "ToastPlaylistCreateFailed": "Falha ao criar lista de reprodução", "ToastPlaylistCreateSuccess": "Lista de reprodução criada", - "ToastPlaylistRemoveFailed": "Falha ao remover lista de reprodução", "ToastPlaylistRemoveSuccess": "Lista de reprodução removida", "ToastPlaylistUpdateFailed": "Falha ao atualizar lista de reprodução", "ToastPlaylistUpdateSuccess": "Lista de reprodução atualizada", diff --git a/client/strings/ru.json b/client/strings/ru.json index f0cf560046..85727dd1b5 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию", "LabelAddToPlaylist": "Добавить в плейлист", "LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист", - "LabelAdded": "Добавили", "LabelAddedAt": "Дата добавления", "LabelAdminUsersOnly": "Только для пользователей с правами администратора", "LabelAll": "Все", @@ -692,7 +691,6 @@ "MessageNoSeries": "Нет серий", "MessageNoTags": "Нет тегов", "MessageNoTasksRunning": "Нет выполняемых задач", - "MessageNoUpdateNecessary": "Обновление не требуется", "MessageNoUpdatesWereNecessary": "Обновления не требовались", "MessageNoUserPlaylists": "У вас нет плейлистов", "MessageNotYetImplemented": "Пока не реализовано", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Поиск эпизода...", "ToastAccountUpdateFailed": "Не удалось обновить учетную запись", "ToastAccountUpdateSuccess": "Учетная запись обновлена", - "ToastAuthorImageRemoveFailed": "Не удалось удалить изображение", "ToastAuthorImageRemoveSuccess": "Изображение автора удалено", "ToastAuthorUpdateFailed": "Не удалось обновить автора", "ToastAuthorUpdateMerged": "Автор объединен", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Успешное пакетное обновление", "ToastBookmarkCreateFailed": "Не удалось создать закладку", "ToastBookmarkCreateSuccess": "Добавлена закладка", - "ToastBookmarkRemoveFailed": "Не удалось удалить закладку", "ToastBookmarkRemoveSuccess": "Закладка удалена", "ToastBookmarkUpdateFailed": "Не удалось обновить закладку", "ToastBookmarkUpdateSuccess": "Закладка обновлена", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Кэш успешно очищен", "ToastChaptersHaveErrors": "Главы имеют ошибки", "ToastChaptersMustHaveTitles": "Главы должны содержать названия", - "ToastCollectionItemsRemoveFailed": "Не удалось удалить элемент(ы) из коллекции", "ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции", - "ToastCollectionRemoveFailed": "Не удалось удалить коллекцию", "ToastCollectionRemoveSuccess": "Коллекция удалена", "ToastCollectionUpdateFailed": "Не удалось обновить коллекцию", "ToastCollectionUpdateSuccess": "Коллекция обновлена", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Обложка элемента обновлена", "ToastItemDetailsUpdateFailed": "Не удалось обновить сведения об элементе", "ToastItemDetailsUpdateSuccess": "Обновлены сведения об элементе", - "ToastItemDetailsUpdateUnneeded": "Для сведений об элементе не требуется никаких обновлений", "ToastItemMarkedAsFinishedFailed": "Не удалось пометить как Завершенный", "ToastItemMarkedAsFinishedSuccess": "Элемент помечен как Завершенный", "ToastItemMarkedAsNotFinishedFailed": "Не удалось пометить как Незавершенный", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Библиотека \"{0}\" обновлена", "ToastPlaylistCreateFailed": "Не удалось создать плейлист", "ToastPlaylistCreateSuccess": "Плейлист создан", - "ToastPlaylistRemoveFailed": "Не удалось удалить плейлист", "ToastPlaylistRemoveSuccess": "Плейлист удален", "ToastPlaylistUpdateFailed": "Не удалось обновить плейлист", "ToastPlaylistUpdateSuccess": "Плейлист обновлен", diff --git a/client/strings/sv.json b/client/strings/sv.json index c301109b78..d956100548 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Lägg till {0} böcker i Samlingen", "LabelAddToPlaylist": "Lägg till i Spellista", "LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan", - "LabelAdded": "Tillagd", "LabelAddedAt": "Tillagd vid", "LabelAdminUsersOnly": "Endast administratörer", "LabelAll": "Alla", @@ -693,7 +692,6 @@ "MessageNoSeries": "Inga serier", "MessageNoTags": "Inga taggar", "MessageNoTasksRunning": "Inga pågående uppgifter", - "MessageNoUpdateNecessary": "Ingen uppdatering krävs", "MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga", "MessageNoUserPlaylists": "Du har inga spellistor", "MessageNotYetImplemented": "Ännu inte implementerad", @@ -740,7 +738,6 @@ "PlaceholderSearchEpisode": "Sök avsnitt...", "ToastAccountUpdateFailed": "Det gick inte att uppdatera kontot", "ToastAccountUpdateSuccess": "Kontot uppdaterat", - "ToastAuthorImageRemoveFailed": "Det gick inte att ta bort författarens bild", "ToastAuthorImageRemoveSuccess": "Författarens bild borttagen", "ToastAuthorUpdateFailed": "Det gick inte att uppdatera författaren", "ToastAuthorUpdateMerged": "Författaren sammanslagen", @@ -757,7 +754,6 @@ "ToastBatchUpdateSuccess": "Batchuppdateringen lyckades", "ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket", "ToastBookmarkCreateSuccess": "Bokmärket tillagt", - "ToastBookmarkRemoveFailed": "Det gick inte att ta bort bokmärket", "ToastBookmarkRemoveSuccess": "Bokmärket borttaget", "ToastBookmarkUpdateFailed": "Det gick inte att uppdatera bokmärket", "ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat", @@ -765,9 +761,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", - "ToastCollectionItemsRemoveFailed": "Det gick inte att ta bort objekt från samlingen", "ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen", - "ToastCollectionRemoveFailed": "Det gick inte att ta bort samlingen", "ToastCollectionRemoveSuccess": "Samlingen borttagen", "ToastCollectionUpdateFailed": "Det gick inte att uppdatera samlingen", "ToastCollectionUpdateSuccess": "Samlingen uppdaterad", @@ -778,7 +772,6 @@ "ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat", "ToastItemDetailsUpdateFailed": "Det gick inte att uppdatera objektdetaljerna", "ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade", - "ToastItemDetailsUpdateUnneeded": "Inga uppdateringar behövs för objektdetaljerna", "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig", "ToastItemMarkedAsFinishedSuccess": "Objekt markerat som färdig", "ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera som ej färdig", @@ -793,7 +786,6 @@ "ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" uppdaterat", "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", "ToastPlaylistCreateSuccess": "Spellistan skapad", - "ToastPlaylistRemoveFailed": "Det gick inte att ta bort spellistan", "ToastPlaylistRemoveSuccess": "Spellistan borttagen", "ToastPlaylistUpdateFailed": "Det gick inte att uppdatera spellistan", "ToastPlaylistUpdateSuccess": "Spellistan uppdaterad", diff --git a/client/strings/uk.json b/client/strings/uk.json index 27ef6b0275..0fec59a823 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -205,7 +205,6 @@ "LabelAddToCollectionBatch": "Додати книги до добірки: {0}", "LabelAddToPlaylist": "Додати до списку відтворення", "LabelAddToPlaylistBatch": "Додано елементів у список відтворення: {0}", - "LabelAdded": "Додано", "LabelAddedAt": "Дата додавання", "LabelAdminUsersOnly": "Тільки для адміністраторів", "LabelAll": "Усе", @@ -722,7 +721,6 @@ "MessageNoSeries": "Без серії", "MessageNoTags": "Без міток", "MessageNoTasksRunning": "Немає активних завдань", - "MessageNoUpdateNecessary": "Оновлення не потрібно", "MessageNoUpdatesWereNecessary": "Оновлень не потрібно", "MessageNoUserPlaylists": "У вас немає списків відтворення", "MessageNotYetImplemented": "Ще не реалізовано", @@ -791,7 +789,6 @@ "StatsYearInReview": "ОГЛЯД РОКУ", "ToastAccountUpdateFailed": "Не вдалося оновити профіль", "ToastAccountUpdateSuccess": "Профіль оновлено", - "ToastAuthorImageRemoveFailed": "Не вдалося видалити зображення", "ToastAuthorImageRemoveSuccess": "Фото автора видалено", "ToastAuthorUpdateFailed": "Не вдалося оновити автора", "ToastAuthorUpdateMerged": "Автора об'єднано", @@ -808,7 +805,6 @@ "ToastBatchUpdateSuccess": "Обрані успішно оновлено", "ToastBookmarkCreateFailed": "Не вдалося створити закладку", "ToastBookmarkCreateSuccess": "Закладку додано", - "ToastBookmarkRemoveFailed": "Не вдалося видалити закладку", "ToastBookmarkRemoveSuccess": "Закладку видалено", "ToastBookmarkUpdateFailed": "Не вдалося оновити закладку", "ToastBookmarkUpdateSuccess": "Закладку оновлено", @@ -816,9 +812,7 @@ "ToastCachePurgeSuccess": "Кеш очищено", "ToastChaptersHaveErrors": "Глави містять помилки", "ToastChaptersMustHaveTitles": "Глави повинні мати назви", - "ToastCollectionItemsRemoveFailed": "Не вдалося видалити елемент(и) з добірки", "ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки", - "ToastCollectionRemoveFailed": "Не вдалося видалити добірку", "ToastCollectionRemoveSuccess": "Добірку видалено", "ToastCollectionUpdateFailed": "Не вдалося оновити добірку", "ToastCollectionUpdateSuccess": "Добірку оновлено", @@ -830,7 +824,6 @@ "ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено", "ToastItemDetailsUpdateFailed": "Не вдалося оновити подробиці елемента", "ToastItemDetailsUpdateSuccess": "Подробиці про елемент оновлено", - "ToastItemDetailsUpdateUnneeded": "Оновлення подробиць непотрібне", "ToastItemMarkedAsFinishedFailed": "Не вдалося позначити як завершене", "ToastItemMarkedAsFinishedSuccess": "Елемент позначено як завершений", "ToastItemMarkedAsNotFinishedFailed": "Не вдалося позначити незавершеним", @@ -845,7 +838,6 @@ "ToastLibraryUpdateSuccess": "Бібліотеку \"{0}\" оновлено", "ToastPlaylistCreateFailed": "Не вдалося створити список", "ToastPlaylistCreateSuccess": "Список відтворення створено", - "ToastPlaylistRemoveFailed": "Не вдалося видалити список", "ToastPlaylistRemoveSuccess": "Список відтворення видалено", "ToastPlaylistUpdateFailed": "Не вдалося оновити список", "ToastPlaylistUpdateSuccess": "Список відтворення оновлено", diff --git a/client/strings/vi-vn.json b/client/strings/vi-vn.json index b92f481893..74d9478866 100644 --- a/client/strings/vi-vn.json +++ b/client/strings/vi-vn.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "Thêm {0} Sách vào Bộ Sưu Tập", "LabelAddToPlaylist": "Thêm vào Danh Sách Phát", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", - "LabelAdded": "Đã Thêm", "LabelAddedAt": "Đã Thêm Lúc", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", @@ -692,7 +691,6 @@ "MessageNoSeries": "Không có Bộ", "MessageNoTags": "Không có Thẻ", "MessageNoTasksRunning": "Không có Công việc đang chạy", - "MessageNoUpdateNecessary": "Không cần cập nhật", "MessageNoUpdatesWereNecessary": "Không cần cập nhật", "MessageNoUserPlaylists": "Bạn chưa có danh sách phát", "MessageNotYetImplemented": "Chưa được triển khai", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "Tìm kiếm tập..", "ToastAccountUpdateFailed": "Cập nhật tài khoản thất bại", "ToastAccountUpdateSuccess": "Tài khoản đã được cập nhật", - "ToastAuthorImageRemoveFailed": "Không thể xóa ảnh tác giả", "ToastAuthorImageRemoveSuccess": "Ảnh tác giả đã được xóa", "ToastAuthorUpdateFailed": "Cập nhật tác giả thất bại", "ToastAuthorUpdateMerged": "Tác giả đã được hợp nhất", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "Cập nhật nhóm thành công", "ToastBookmarkCreateFailed": "Tạo đánh dấu thất bại", "ToastBookmarkCreateSuccess": "Đã thêm đánh dấu", - "ToastBookmarkRemoveFailed": "Xóa đánh dấu thất bại", "ToastBookmarkRemoveSuccess": "Đánh dấu đã được xóa", "ToastBookmarkUpdateFailed": "Cập nhật đánh dấu thất bại", "ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "Các chương có lỗi", "ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề", - "ToastCollectionItemsRemoveFailed": "Xóa mục từ bộ sưu tập thất bại", "ToastCollectionItemsRemoveSuccess": "Mục đã được xóa khỏi bộ sưu tập", - "ToastCollectionRemoveFailed": "Xóa bộ sưu tập thất bại", "ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa", "ToastCollectionUpdateFailed": "Cập nhật bộ sưu tập thất bại", "ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật", "ToastItemDetailsUpdateFailed": "Cập nhật chi tiết mục thất bại", "ToastItemDetailsUpdateSuccess": "Chi tiết mục đã được cập nhật", - "ToastItemDetailsUpdateUnneeded": "Không cần cập nhật chi tiết mục", "ToastItemMarkedAsFinishedFailed": "Đánh dấu mục là Hoàn thành thất bại", "ToastItemMarkedAsFinishedSuccess": "Mục đã được đánh dấu là Hoàn thành", "ToastItemMarkedAsNotFinishedFailed": "Đánh dấu mục là Chưa hoàn thành thất bại", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "Thư viện \"{0}\" đã được cập nhật", "ToastPlaylistCreateFailed": "Tạo danh sách phát thất bại", "ToastPlaylistCreateSuccess": "Danh sách phát đã được tạo", - "ToastPlaylistRemoveFailed": "Xóa danh sách phát thất bại", "ToastPlaylistRemoveSuccess": "Danh sách phát đã được xóa", "ToastPlaylistUpdateFailed": "Cập nhật danh sách phát thất bại", "ToastPlaylistUpdateSuccess": "Danh sách phát đã được cập nhật", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 97f47390ce..72233a1cc6 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -205,7 +205,6 @@ "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏", "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", - "LabelAdded": "添加", "LabelAddedAt": "添加于", "LabelAdminUsersOnly": "仅限管理员用户", "LabelAll": "全部", @@ -722,7 +721,6 @@ "MessageNoSeries": "无系列", "MessageNoTags": "无标签", "MessageNoTasksRunning": "没有正在运行的任务", - "MessageNoUpdateNecessary": "无需更新", "MessageNoUpdatesWereNecessary": "无需更新", "MessageNoUserPlaylists": "你没有播放列表", "MessageNotYetImplemented": "尚未实施", @@ -791,7 +789,6 @@ "StatsYearInReview": "年度回顾", "ToastAccountUpdateFailed": "账户更新失败", "ToastAccountUpdateSuccess": "帐户已更新", - "ToastAuthorImageRemoveFailed": "作者图像删除失败", "ToastAuthorImageRemoveSuccess": "作者图像已删除", "ToastAuthorUpdateFailed": "作者更新失败", "ToastAuthorUpdateMerged": "作者已合并", @@ -808,7 +805,6 @@ "ToastBatchUpdateSuccess": "批量更新成功", "ToastBookmarkCreateFailed": "创建书签失败", "ToastBookmarkCreateSuccess": "书签已添加", - "ToastBookmarkRemoveFailed": "书签删除失败", "ToastBookmarkRemoveSuccess": "书签已删除", "ToastBookmarkUpdateFailed": "书签更新失败", "ToastBookmarkUpdateSuccess": "书签已更新", @@ -816,9 +812,7 @@ "ToastCachePurgeSuccess": "缓存清除成功", "ToastChaptersHaveErrors": "章节有错误", "ToastChaptersMustHaveTitles": "章节必须有标题", - "ToastCollectionItemsRemoveFailed": "从收藏夹移除项目失败", "ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除", - "ToastCollectionRemoveFailed": "删除收藏夹失败", "ToastCollectionRemoveSuccess": "收藏夹已删除", "ToastCollectionUpdateFailed": "更新收藏夹失败", "ToastCollectionUpdateSuccess": "收藏夹已更新", @@ -830,7 +824,6 @@ "ToastItemCoverUpdateSuccess": "项目封面已更新", "ToastItemDetailsUpdateFailed": "更新项目详细信息失败", "ToastItemDetailsUpdateSuccess": "项目详细信息已更新", - "ToastItemDetailsUpdateUnneeded": "项目详细信息无需更新", "ToastItemMarkedAsFinishedFailed": "无法标记为已听完", "ToastItemMarkedAsFinishedSuccess": "标记为已听完的项目", "ToastItemMarkedAsNotFinishedFailed": "无法标记为未听完", @@ -845,7 +838,6 @@ "ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新", "ToastPlaylistCreateFailed": "创建播放列表失败", "ToastPlaylistCreateSuccess": "已成功创建播放列表", - "ToastPlaylistRemoveFailed": "删除播放列表失败", "ToastPlaylistRemoveSuccess": "播放列表已删除", "ToastPlaylistUpdateFailed": "更新播放列表失败", "ToastPlaylistUpdateSuccess": "播放列表已更新", diff --git a/client/strings/zh-tw.json b/client/strings/zh-tw.json index 8687053f84..2a4ae7033a 100644 --- a/client/strings/zh-tw.json +++ b/client/strings/zh-tw.json @@ -202,7 +202,6 @@ "LabelAddToCollectionBatch": "批量新增 {0} 個媒體到收藏", "LabelAddToPlaylist": "新增到播放列表", "LabelAddToPlaylistBatch": "新增 {0} 個項目到播放列表", - "LabelAdded": "新增", "LabelAddedAt": "新增於", "LabelAdminUsersOnly": "僅限管理員使用者", "LabelAll": "全部", @@ -692,7 +691,6 @@ "MessageNoSeries": "無系列", "MessageNoTags": "無標籤", "MessageNoTasksRunning": "沒有正在運行的任務", - "MessageNoUpdateNecessary": "無需更新", "MessageNoUpdatesWereNecessary": "無需更新", "MessageNoUserPlaylists": "你沒有播放列表", "MessageNotYetImplemented": "尚未實施", @@ -739,7 +737,6 @@ "PlaceholderSearchEpisode": "搜尋劇集..", "ToastAccountUpdateFailed": "帳號更新失敗", "ToastAccountUpdateSuccess": "帳號已更新", - "ToastAuthorImageRemoveFailed": "作者圖像刪除失敗", "ToastAuthorImageRemoveSuccess": "作者圖像已刪除", "ToastAuthorUpdateFailed": "作者更新失敗", "ToastAuthorUpdateMerged": "作者已合併", @@ -756,7 +753,6 @@ "ToastBatchUpdateSuccess": "批量更新成功", "ToastBookmarkCreateFailed": "創建書籤失敗", "ToastBookmarkCreateSuccess": "書籤已新增", - "ToastBookmarkRemoveFailed": "書籤刪除失敗", "ToastBookmarkRemoveSuccess": "書籤已刪除", "ToastBookmarkUpdateFailed": "書籤更新失敗", "ToastBookmarkUpdateSuccess": "書籤已更新", @@ -764,9 +760,7 @@ "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChaptersHaveErrors": "章節有錯誤", "ToastChaptersMustHaveTitles": "章節必須有標題", - "ToastCollectionItemsRemoveFailed": "從收藏夾移除項目失敗", "ToastCollectionItemsRemoveSuccess": "項目從收藏夾移除", - "ToastCollectionRemoveFailed": "刪除收藏夾失敗", "ToastCollectionRemoveSuccess": "收藏夾已刪除", "ToastCollectionUpdateFailed": "更新收藏夾失敗", "ToastCollectionUpdateSuccess": "收藏夾已更新", @@ -777,7 +771,6 @@ "ToastItemCoverUpdateSuccess": "項目封面已更新", "ToastItemDetailsUpdateFailed": "更新項目詳細信息失敗", "ToastItemDetailsUpdateSuccess": "項目詳細信息已更新", - "ToastItemDetailsUpdateUnneeded": "項目詳細信息無需更新", "ToastItemMarkedAsFinishedFailed": "標記為聽完失敗", "ToastItemMarkedAsFinishedSuccess": "標記為聽完的項目", "ToastItemMarkedAsNotFinishedFailed": "標記為未聽完失敗", @@ -792,7 +785,6 @@ "ToastLibraryUpdateSuccess": "媒體庫 \"{0}\" 已更新", "ToastPlaylistCreateFailed": "創建播放列表失敗", "ToastPlaylistCreateSuccess": "已成功創建播放列表", - "ToastPlaylistRemoveFailed": "刪除播放列表失敗", "ToastPlaylistRemoveSuccess": "播放列表已刪除", "ToastPlaylistUpdateFailed": "更新播放列表失敗", "ToastPlaylistUpdateSuccess": "播放列表已更新", From 064679c05719e7bf236fe579acec4c7a3dc10bb9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 31 Aug 2024 14:59:42 -0500 Subject: [PATCH 039/539] Update:Author number of books sort fallsback to sort on name when num books is the same --- client/pages/library/_library/authors/index.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/pages/library/_library/authors/index.vue b/client/pages/library/_library/authors/index.vue index 75e9f083ec..9820fbce63 100644 --- a/client/pages/library/_library/authors/index.vue +++ b/client/pages/library/_library/authors/index.vue @@ -61,6 +61,8 @@ export default { const bDesc = this.authorSortDesc ? -1 : 1 return this.authors.sort((a, b) => { if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') { + // Fallback to name sort if equal + if (a[sortProp] === b[sortProp]) return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) * bDesc return a[sortProp] > b[sortProp] ? bDesc : -bDesc } return a[sortProp]?.localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc From 48b703bf9f8b588c9ede25a27773a9ed936e7ba7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 31 Aug 2024 15:03:42 -0500 Subject: [PATCH 040/539] Update:Global search author card and library stat author name links to author page --- client/components/controls/GlobalSearch.vue | 2 +- client/pages/library/_library/stats.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/controls/GlobalSearch.vue b/client/components/controls/GlobalSearch.vue index 2b3fc5737f..cb46895a59 100644 --- a/client/components/controls/GlobalSearch.vue +++ b/client/components/controls/GlobalSearch.vue @@ -42,7 +42,7 @@

{{ $strings.LabelAuthors }}

@@ -170,7 +170,8 @@ export default { abridged: false }, appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'], - openMapOptions: false + openMapOptions: false, + itemsWithChanges: [] } }, computed: { @@ -221,9 +222,19 @@ export default { }, hasSelectedBatchUsage() { return Object.values(this.selectedBatchUsage).some((b) => !!b) + }, + hasChanges() { + return this.itemsWithChanges.length > 0 } }, methods: { + handleItemChange(itemChange) { + if (!itemChange.hasChanges) { + this.itemsWithChanges = this.itemsWithChanges.filter((id) => id !== itemChange.libraryItemId) + } else if (!this.itemsWithChanges.includes(itemChange.libraryItemId)) { + this.itemsWithChanges.push(itemChange.libraryItemId) + } + }, blurBatchForm() { if (this.$refs.seriesSelect && this.$refs.seriesSelect.isFocused) { this.$refs.seriesSelect.forceBlur() @@ -283,38 +294,10 @@ export default { removedSeriesItem(item) {}, newNarratorItem(item) {}, removedNarratorItem(item) {}, - newTagItem(item) { - // if (item && !this.newTagItems.includes(item)) { - // this.newTagItems.push(item) - // } - }, - removedTagItem(item) { - // If newly added, remove if not used on any other items - // if (item && this.newTagItems.includes(item)) { - // var usedByOtherAb = this.libraryItemCopies.find((ab) => { - // return ab.tags && ab.tags.includes(item) - // }) - // if (!usedByOtherAb) { - // this.newTagItems = this.newTagItems.filter((t) => t !== item) - // } - // } - }, - newGenreItem(item) { - // if (item && !this.newGenreItems.includes(item)) { - // this.newGenreItems.push(item) - // } - }, - removedGenreItem(item) { - // If newly added, remove if not used on any other items - // if (item && this.newGenreItems.includes(item)) { - // var usedByOtherAb = this.libraryItemCopies.find((ab) => { - // return ab.book.genres && ab.book.genres.includes(item) - // }) - // if (!usedByOtherAb) { - // this.newGenreItems = this.newGenreItems.filter((t) => t !== item) - // } - // } - }, + newTagItem(item) {}, + removedTagItem(item) {}, + newGenreItem(item) {}, + removedGenreItem(item) {}, init() { // TODO: Better deep cloning of library items this.libraryItemCopies = this.libraryItems.map((li) => { @@ -376,6 +359,7 @@ export default { .then((data) => { this.isProcessing = false if (data.updates) { + this.itemsWithChanges = [] this.$toast.success(`Successfully updated ${data.updates} items`) this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`) } else { @@ -387,10 +371,28 @@ export default { this.$toast.error('Failed to batch update') this.isProcessing = false }) + }, + beforeUnload(e) { + if (!e || !this.hasChanges) return + e.preventDefault() + e.returnValue = '' + } + }, + beforeRouteLeave(to, from, next) { + if (this.hasChanges) { + next(false) + window.location = to.path + } else { + next() } }, mounted() { this.init() + + window.addEventListener('beforeunload', this.beforeUnload) + }, + beforeDestroy() { + window.removeEventListener('beforeunload', this.beforeUnload) } } From e20563f2e1eb3ea360d3cee7489cc7746519d8fd Mon Sep 17 00:00:00 2001 From: Soaibuzzaman Date: Mon, 2 Sep 2024 07:03:29 +0000 Subject: [PATCH 059/539] Translated using Weblate (Bengali) Currently translated at 82.0% (799 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/ --- client/strings/bn.json | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/client/strings/bn.json b/client/strings/bn.json index 04c4e968d4..d0fddf9e78 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -9,6 +9,7 @@ "ButtonApply": "প্রয়োগ করুন", "ButtonApplyChapters": "অধ্যায় প্রয়োগ করুন", "ButtonAuthors": "লেখক", + "ButtonBack": "পেছনে যান", "ButtonBrowseForFolder": "ফোল্ডারের জন্য ব্রাউজ করুন", "ButtonCancel": "বাতিল করুন", "ButtonCancelEncode": "এনকোড বাতিল করুন", @@ -18,6 +19,7 @@ "ButtonChooseFiles": "ফাইল চয়ন করুন", "ButtonClearFilter": "ফিল্টার পরিষ্কার করুন", "ButtonCloseFeed": "ফিড বন্ধ করুন", + "ButtonCloseSession": "খোলা সেশন বন্ধ করুন", "ButtonCollections": "সংগ্রহ", "ButtonConfigureScanner": "স্ক্যানার কনফিগার করুন", "ButtonCreate": "তৈরি করুন", @@ -27,6 +29,9 @@ "ButtonEdit": "সম্পাদনা করুন", "ButtonEditChapters": "অধ্যায় সম্পাদনা করুন", "ButtonEditPodcast": "পডকাস্ট সম্পাদনা করুন", + "ButtonEnable": "সক্রিয় করুন", + "ButtonFireAndFail": "সক্রিয় এবং ব্যর্থ", + "ButtonFireOnTest": "পরীক্ষামূলক ইভেন্টে সক্রিয় করুন", "ButtonForceReScan": "জোরপূর্বক পুনরায় স্ক্যান করুন", "ButtonFullPath": "সম্পূর্ণ পথ", "ButtonHide": "লুকান", @@ -45,6 +50,7 @@ "ButtonNevermind": "কিছু মনে করবেন না", "ButtonNext": "পরবর্তী", "ButtonNextChapter": "পরবর্তী অধ্যায়", + "ButtonNextItemInQueue": "সারিতে পরের আইটেম", "ButtonOk": "ঠিক আছে", "ButtonOpenFeed": "ফিড খুলুন", "ButtonOpenManager": "ম্যানেজার খুলুন", @@ -54,13 +60,17 @@ "ButtonPlaylists": "প্লেলিস্ট", "ButtonPrevious": "পূর্ববর্তী", "ButtonPreviousChapter": "আগের অধ্যায়", + "ButtonProbeAudioFile": "প্রোব অডিও ফাইল", "ButtonPurgeAllCache": "সমস্ত ক্যাশে পরিষ্কার করুন", "ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন", "ButtonQueueAddItem": "সারিতে যোগ করুন", "ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন", + "ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন", "ButtonQuickMatch": "দ্রুত ম্যাচ", "ButtonReScan": "পুনরায় স্ক্যান", "ButtonRead": "পড়ুন", + "ButtonReadLess": "সংক্ষিপ্ত", + "ButtonReadMore": "বিস্তারিত পড়ুন", "ButtonRefresh": "রিফ্রেশ", "ButtonRemove": "মুছে ফেলুন", "ButtonRemoveAll": "সব মুছে ফেলুন", @@ -85,8 +95,10 @@ "ButtonShow": "দেখান", "ButtonStartM4BEncode": "M4B এনকোড শুরু করুন", "ButtonStartMetadataEmbed": "মেটাডেটা এম্বেড শুরু করুন", + "ButtonStats": "পরিসংখ্যান", "ButtonSubmit": "জমা দিন", "ButtonTest": "পরীক্ষা", + "ButtonUnlinkOpedId": "ওপেন আইডি লিংকমুক্ত করুন", "ButtonUpload": "আপলোড", "ButtonUploadBackup": "আপলোড ব্যাকআপ", "ButtonUploadCover": "কভার আপলোড করুন", @@ -99,9 +111,10 @@ "ErrorUploadFetchMetadataNoResults": "মেটাডেটা আনা যায়নি - শিরোনাম এবং/অথবা লেখক আপডেট করার চেষ্টা করুন", "ErrorUploadLacksTitle": "একটি শিরোনাম থাকতে হবে", "HeaderAccount": "অ্যাকাউন্ট", + "HeaderAddCustomMetadataProvider": "কাস্টম মেটাডেটা সরবরাহকারী যোগ করুন", "HeaderAdvanced": "অ্যাডভান্সড", "HeaderAppriseNotificationSettings": "বিজ্ঞপ্তি সেটিংস অবহিত করুন", - "HeaderAudioTracks": "অডিও ট্র্যাকস", + "HeaderAudioTracks": "অডিও ট্র্যাকসগুলো", "HeaderAudiobookTools": "অডিওবই ফাইল ম্যানেজমেন্ট টুলস", "HeaderAuthentication": "প্রমাণীকরণ", "HeaderBackups": "ব্যাকআপ", @@ -112,6 +125,7 @@ "HeaderCollectionItems": "সংগ্রহ আইটেম", "HeaderCover": "কভার", "HeaderCurrentDownloads": "বর্তমান ডাউনলোডগুলি", + "HeaderCustomMessageOnLogin": "লগইন এ কাস্টম বার্তা", "HeaderCustomMetadataProviders": "কাস্টম মেটাডেটা প্রদানকারী", "HeaderDetails": "বিস্তারিত", "HeaderDownloadQueue": "ডাউনলোড সারি", @@ -143,6 +157,8 @@ "HeaderMetadataToEmbed": "এম্বেড করার জন্য মেটাডেটা", "HeaderNewAccount": "নতুন অ্যাকাউন্ট", "HeaderNewLibrary": "নতুন লাইব্রেরি", + "HeaderNotificationCreate": "বিজ্ঞপ্তি তৈরি করুন", + "HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন", "HeaderNotifications": "বিজ্ঞপ্তি", "HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ", "HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন", @@ -150,6 +166,7 @@ "HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ", "HeaderPermissions": "অনুমতি", "HeaderPlayerQueue": "প্লেয়ার সারি", + "HeaderPlayerSettings": "প্লেয়ার সেটিংস", "HeaderPlaylist": "প্লেলিস্ট", "HeaderPlaylistItems": "প্লেলিস্ট আইটেম", "HeaderPodcastsToAdd": "যোগ করার জন্য পডকাস্ট", @@ -186,6 +203,9 @@ "HeaderYearReview": "বাৎসরিক পর্যালোচনা {0}", "HeaderYourStats": "আপনার পরিসংখ্যান", "LabelAbridged": "সংক্ষিপ্ত", + "LabelAbridgedChecked": "সংক্ষিপ্ত (চেক)", + "LabelAbridgedUnchecked": "অসংক্ষেপিত (চেক করা হয়নি)", + "LabelAccessibleBy": "দ্বারা প্রবেশযোগ্য", "LabelAccountType": "অ্যাকাউন্টের প্রকার", "LabelAccountTypeAdmin": "প্রশাসন", "LabelAccountTypeGuest": "অতিথি", @@ -196,6 +216,7 @@ "LabelAddToPlaylist": "প্লেলিস্টে যোগ করুন", "LabelAddToPlaylistBatch": "প্লেলিস্টে {0}টি আইটেম যোগ করুন", "LabelAddedAt": "এতে যোগ করা হয়েছে", + "LabelAddedDate": "যোগ করা হয়েছে {0}", "LabelAdminUsersOnly": "শুধু অ্যাডমিন ব্যবহারকারী", "LabelAll": "সব", "LabelAllUsers": "সমস্ত ব্যবহারকারী", @@ -225,6 +246,7 @@ "LabelBitrate": "বিটরেট", "LabelBooks": "বইগুলো", "LabelButtonText": "ঘর পাঠ্য", + "LabelByAuthor": "দ্বারা {0}", "LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন", "LabelChannels": "চ্যানেল", "LabelChapterTitle": "অধ্যায়ের শিরোনাম", @@ -234,6 +256,7 @@ "LabelClosePlayer": "প্লেয়ার বন্ধ করুন", "LabelCodec": "কোডেক", "LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন", + "LabelCollapseSubSeries": "উপ-সিরিজ সঙ্কুচিত করুন", "LabelCollection": "সংগ্রহ", "LabelCollections": "সংগ্রহ", "LabelComplete": "সম্পূর্ণ", @@ -249,6 +272,7 @@ "LabelCurrently": "বর্তমানে:", "LabelCustomCronExpression": "কাস্টম Cron এক্সপ্রেশন:", "LabelDatetime": "তারিখ সময়", + "LabelDays": "দিনগুলো", "LabelDeleteFromFileSystemCheckbox": "ফাইল সিস্টেম থেকে মুছে ফেলুন (শুধু ডাটাবেস থেকে সরাতে টিক চিহ্ন মুক্ত করুন)", "LabelDescription": "বিবরণ", "LabelDeselectAll": "সমস্ত অনির্বাচিত করুন", @@ -262,12 +286,16 @@ "LabelDownload": "ডাউনলোড করুন", "LabelDownloadNEpisodes": "{0}টি পর্ব ডাউনলোড করুন", "LabelDuration": "সময়কাল", + "LabelDurationComparisonExactMatch": "(সঠিক মিল)", + "LabelDurationComparisonLonger": "({0} দীর্ঘ)", + "LabelDurationComparisonShorter": "({0} ছোট)", "LabelDurationFound": "সময়কাল পাওয়া গেছে:", "LabelEbook": "ই-বই", "LabelEbooks": "ই-বইগুলো", "LabelEdit": "সম্পাদনা করুন", "LabelEmail": "ইমেইল", "LabelEmailSettingsFromAddress": "ঠিকানা থেকে", + "LabelEmailSettingsRejectUnauthorized": "অননুমোদিত সার্টিফিকেট প্রত্যাখ্যান করুন", "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to।", "LabelEmailSettingsSecure": "নিরাপদ", "LabelEmailSettingsSecureHelp": "যদি সত্য হয় সার্ভারের সাথে সংযোগ করার সময় সংযোগটি TLS ব্যবহার করবে। মিথ্যা হলে TLS ব্যবহার করা হবে যদি সার্ভার STARTTLS এক্সটেনশন সমর্থন করে। বেশিরভাগ ক্ষেত্রে এই মানটিকে সত্য হিসাবে সেট করুন যদি আপনি পোর্ট 465-এর সাথে সংযোগ করছেন। পোর্ট 587 বা পোর্টের জন্য 25 এটি মিথ্যা রাখুন। (nodemailer.com/smtp/#authentication থেকে)", @@ -275,10 +303,13 @@ "LabelEmbeddedCover": "এম্বেডেড কভার", "LabelEnable": "সক্ষম করুন", "LabelEnd": "সমাপ্ত", + "LabelEndOfChapter": "অধ্যায়ের সমাপ্তি", "LabelEpisode": "পর্ব", "LabelEpisodeTitle": "পর্বের শিরোনাম", "LabelEpisodeType": "পর্বের ধরন", + "LabelEpisodes": "পর্বগুলো", "LabelExample": "উদাহরণ", + "LabelExpandSeries": "সিরিজ প্রসারিত করুন", "LabelExplicit": "বিশদ", "LabelFeedURL": "ফিড ইউআরএল", "LabelFetchingMetadata": "মেটাডেটা আনা হচ্ছে", From 7a6864507e7e1c4ef65fe16ad7ab248343c647d3 Mon Sep 17 00:00:00 2001 From: Andrej Kralj Date: Mon, 2 Sep 2024 04:53:34 +0000 Subject: [PATCH 060/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 528 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 527 insertions(+), 1 deletion(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 4f35ab39c5..e71f31dc46 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -445,6 +445,532 @@ "LabelPermissionsDownload": "Lahko prenaša", "LabelPermissionsUpdate": "Lahko posodablja", "LabelPermissionsUpload": "Lahko nalaga", + "LabelPersonalYearReview": "Pregled tvojega leta ({0})", + "LabelPhotoPathURL": "Slika pot/URL", + "LabelPlayMethod": "Metoda predvajanja", + "LabelPlayerChapterNumberMarker": "{0} od {1}", + "LabelPlaylists": "Seznami predvajanja", + "LabelPodcast": "Podcast", + "LabelPodcastSearchRegion": "Regija iskanja podcastov", + "LabelPodcastType": "Vrsta podcasta", + "LabelPodcasts": "Podcasti", + "LabelPort": "Vrata", + "LabelPrefixesToIgnore": "Predpone, ki jih je treba prezreti (neobčutljivo na velike in male črke)", + "LabelPreventIndexing": "Preprečite, da bi vaš vir indeksirali imeniki podcastov iTunes in Google", + "LabelPrimaryEbook": "Primarna eknjiga", + "LabelProgress": "Napredek", + "LabelProvider": "Ponudnik", + "LabelProviderAuthorizationValue": "Vrednost glave avtorizacije", + "LabelPubDate": "Datum objave", + "LabelPublishYear": "Leto objave", + "LabelPublishedDate": "Objavljeno {0}", + "LabelPublisher": "Založnik", + "LabelPublishers": "Založniki", + "LabelRSSFeedCustomOwnerEmail": "E-pošta lastnika po meri", + "LabelRSSFeedCustomOwnerName": "Ime lastnika po meri", + "LabelRSSFeedOpen": "Odprt vir RSS", + "LabelRSSFeedPreventIndexing": "Prepreči indeksiranje", + "LabelRSSFeedSlug": "Slug RSS vira", + "LabelRSSFeedURL": "URL vira RSS", + "LabelRandomly": "Naključno", + "LabelReAddSeriesToContinueListening": "Znova dodaj serijo za nadaljevanje poslušanja", + "LabelRead": "Preberi", + "LabelReadAgain": "Ponovno preberi", + "LabelReadEbookWithoutProgress": "Preberi eknjigo brez ohranjanja napredka", + "LabelRecentSeries": "Nedavne serije", + "LabelRecentlyAdded": "Nedavno dodano", + "LabelRecommended": "Priporočeno", + "LabelRedo": "Ponovi", + "LabelRegion": "Regija", + "LabelReleaseDate": "Datum izdaje", + "LabelRemoveCover": "Odstrani naslovnico", + "LabelRowsPerPage": "Vrstic na stran", + "LabelSearchTerm": "Iskalni pojem", + "LabelSearchTitle": "Naslov iskanja", + "LabelSearchTitleOrASIN": "Naslov iskanja ali ASIN", + "LabelSeason": "Sezona", + "LabelSelectAll": "Izberi vse", + "LabelSelectAllEpisodes": "Izberi vse epizode", + "LabelSelectEpisodesShowing": "Izberi {0} prikazanih epizod", + "LabelSelectUsers": "Izberi uporabnike", + "LabelSendEbookToDevice": "Pošlji eknjigo k...", + "LabelSequence": "Zaporedje", + "LabelSeries": "Serije", + "LabelSeriesName": "Ime serije", + "LabelSeriesProgress": "Napredek serije", + "LabelServerYearReview": "Pregled leta strežnika ({0})", + "LabelSetEbookAsPrimary": "Nastavi kot primarno", + "LabelSetEbookAsSupplementary": "Nastavi kot dodatno", + "LabelSettingsAudiobooksOnly": "Samo zvočne knjige", + "LabelSettingsAudiobooksOnlyHelp": "Če omogočite to nastavitev, bodo datoteke eknjig prezrte, razen če so znotraj mape zvočnih knjig, v tem primeru bodo nastavljene kot dodatne e-knjige", + "LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami", + "LabelSettingsChromecastSupport": "Podpora za Chromecast", + "LabelSettingsDateFormat": "Oblika datuma", + "LabelSettingsDisableWatcher": "Onemogoči pregledovalca", + "LabelSettingsDisableWatcherForLibrary": "Onemogoči pregledovalca map za knjižnico", + "LabelSettingsDisableWatcherHelp": "Onemogoči samodejno dodajanje/posodabljanje elementov, ko so zaznane spremembe datoteke. *Potreben je ponovni zagon strežnika", + "LabelSettingsEnableWatcher": "Omogoči pregledovalca", + "LabelSettingsEnableWatcherForLibrary": "Omogoči pregledovalca map za knjižnico", + "LabelSettingsEnableWatcherHelp": "Omogoča samodejno dodajanje/posodabljanje elementov, ko so zaznane spremembe datoteke. *Potreben je ponovni zagon strežnika", + "LabelSettingsEpubsAllowScriptedContent": "Dovoli skriptirano vsebino v epubih", + "LabelSettingsEpubsAllowScriptedContentHelp": "Dovoli datotekam epub izvajanje skript. Priporočljivo je, da to nastavitev pustite onemogočeno, razen če zaupate viru datotek epub.", + "LabelSettingsExperimentalFeatures": "Eksperimentalne funkcije", + "LabelSettingsExperimentalFeaturesHelp": "Funkcije v razvoju, ki bi lahko uporabile vaše povratne informacije in pomoč pri testiranju. Kliknite, da odprete razpravo na githubu.", + "LabelSettingsFindCovers": "Poišči naslovnice", + "LabelSettingsFindCoversHelp": "Če vaša zvočna knjiga nima vdelane naslovnice ali slike naslovnice v mapi, bo optični bralnik poskušal najti naslovnico.
Opomba: To bo podaljšalo čas skeniranja", + "LabelSettingsHideSingleBookSeries": "Skrij serije s samo eno knjigo", + "LabelSettingsHideSingleBookSeriesHelp": "Serije, ki imajo eno knjigo, bodo skrite na strani serije in policah domače strani.", + "LabelSettingsHomePageBookshelfView": "Domača stran bo imela pogled knjižne police", + "LabelSettingsLibraryBookshelfView": "Knjižnična uporaba pogleda knjižne police", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči prejšnje knjige v nadaljevanju serije", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.", + "LabelSettingsParseSubtitles": "Uporabi podnapise", + "LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.
Podnaslov mora biti ločen z \" - \"
npr. »Naslov knjige – Tu podnapis« ima podnaslov »Tu podnapis«", + "LabelSettingsPreferMatchedMetadata": "Prednost imajo ujemajoči se metapodatki", + "LabelSettingsPreferMatchedMetadataHelp": "Pri uporabi hitrega ujemanja bodo ujemajoči se podatki preglasili podrobnosti artikla. Hitro ujemanje bo privzeto izpolnil samo manjkajoče podrobnosti.", + "LabelSettingsSkipMatchingBooksWithASIN": "Preskoči ujemajoče se knjige, ki že imajo ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Preskoči ujemajoče se knjige, ki že imajo oznako ISBN", + "LabelSettingsSortingIgnorePrefixes": "Pri razvrščanju ne upoštevajte predpon", + "LabelSettingsSortingIgnorePrefixesHelp": "npr. za naslov knjige s predpono \"the\" bi se \"The Book Title\" razvrstil kot \"Book Title, The\"", + "LabelSettingsSquareBookCovers": "Uporabi kvadratne platnice knjig", + "LabelSettingsSquareBookCoversHelp": "Raje uporabi kvadratne platnice kot standardne knjižne platnice 1.6:1", + "LabelSettingsStoreCoversWithItem": "Shrani naslovnice skupaj z elementom", + "LabelSettingsStoreCoversWithItemHelp": "Naslovnice so privzeto shranjene v /metadata/items, če omogočite to nastavitev, bodo platnice shranjene v mapi elementov knjižnice. Shranjena bo samo ena datoteka z imenom \"cover\"", + "LabelSettingsStoreMetadataWithItem": "Shrani metapodatke skupaj z elementom", + "LabelSettingsStoreMetadataWithItemHelp": "Datoteke z metapodatki so privzeto shranjene v /metadata/items, če omogočite to nastavitev, boste datoteke z metapodatki shranili v mape elementov vaše knjižnice", + "LabelSettingsTimeFormat": "Oblika časa", + "LabelShare": "Deli", + "LabelShareOpen": "Deli odprto", + "LabelShareURL": "Deli URL", + "LabelShowAll": "Prikaži vse", + "LabelShowSeconds": "Prikaži sekunde", + "LabelShowSubtitles": "Prikaži podnapise", + "LabelSize": "Velikost", + "LabelSleepTimer": "Časovnik za spanje", + "LabelSlug": "Slug", + "LabelStart": "Začetek", + "LabelStartTime": "Začetni čas", + "LabelStarted": "Začeto", + "LabelStartedAt": "Začeto ob", + "LabelStatsAudioTracks": "Zvočni posnetki", + "LabelStatsAuthors": "Avtorji", + "LabelStatsBestDay": "Najboljši dan", + "LabelStatsDailyAverage": "Dnevno povprečje", + "LabelStatsDays": "Dnevi", + "LabelStatsDaysListened": "Poslušani dnevi", + "LabelStatsHours": "Ure", + "LabelStatsInARow": "v vrsti", + "LabelStatsItemsFinished": "Končani elementi", + "LabelStatsItemsInLibrary": "Elementi v knjižnici", + "LabelStatsMinutes": "minute", + "LabelStatsMinutesListening": "Poslušane minute", + "LabelStatsOverallDays": "Skupaj dnevi", + "LabelStatsOverallHours": "Skupaj ure", + "LabelStatsWeekListening": "Tednov poslušanja", + "LabelSubtitle": "Podnapis", + "LabelSupportedFileTypes": "Podprte vrste datotek", + "LabelTag": "Oznaka", + "LabelTags": "Oznake", + "LabelTagsAccessibleToUser": "Oznake, dostopne uporabniku", + "LabelTagsNotAccessibleToUser": "Oznake, ki niso dostopne uporabniku", + "LabelTasks": "Tekoče naloge", + "LabelTextEditorBulletedList": "Seznam z oznakami", + "LabelTextEditorLink": "Povezava", + "LabelTextEditorNumberedList": "Številčni seznam", + "LabelTextEditorUnlink": "Odveži", + "LabelTheme": "Tema", + "LabelThemeDark": "Temna", + "LabelThemeLight": "Svetla", + "LabelTimeBase": "Odvisna od časa", + "LabelTimeDurationXHours": "{0} ur", + "LabelTimeDurationXMinutes": "{0} minut", + "LabelTimeDurationXSeconds": "{0} sekund", + "LabelTimeInMinutes": "Čas v minutah", + "LabelTimeListened": "Čas poslušanja", + "LabelTimeListenedToday": "Čas poslušanja danes", + "LabelTimeRemaining": "Še {0}", + "LabelTimeToShift": "Čas prestavljanja v sekundah", + "LabelTitle": "Naslov", + "LabelToolsEmbedMetadata": "Vdelaj metapodatke", + "LabelToolsEmbedMetadataDescription": "Vdelajte metapodatke v zvočne datoteke, vključno s sliko naslovnice in poglavji.", + "LabelToolsMakeM4b": "Ustvari datoteko zvočne knjige M4B", + "LabelToolsMakeM4bDescription": "Ustvarite datoteko zvočne knjige .M4B z vdelanimi metapodatki, sliko naslovnice in poglavji.", + "LabelToolsSplitM4b": "Razdeli M4B v MP3 datoteke", + "LabelToolsSplitM4bDescription": "Ustvarite MP3 datoteke iz datoteke M4B, razdeljene po poglavjih z vdelanimi metapodatki, naslovno sliko in poglavji.", + "LabelTotalDuration": "Skupno trajanje", + "LabelTotalTimeListened": "Skupni čas poslušanja", + "LabelTrackFromFilename": "Posnetek iz datoteke", + "LabelTrackFromMetadata": "Posnetek iz metapodatkov", + "LabelTracks": "Posnetki", + "LabelTracksMultiTrack": "Več posnetkov", + "LabelTracksNone": "Brez posnetka", + "LabelTracksSingleTrack": "Enojni posnetek", + "LabelType": "Vrsta", + "LabelUnabridged": "Neskrajšano", + "LabelUndo": "Razveljavi", + "LabelUnknown": "Neznano", + "LabelUnknownPublishDate": "Neznan datum objave", + "LabelUpdateCover": "Posodobi naslovnico", + "LabelUpdateCoverHelp": "Dovoli prepisovanje obstoječih naslovnic za izbrane knjige, ko se najde ujemanje", + "LabelUpdateDetails": "Posodobi podrobnosti", + "LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje", + "LabelUpdatedAt": "Posodobljeno ob", + "LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape", + "LabelUploaderDropFiles": "Spusti datoteke", + "LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo", + "LabelUseChapterTrack": "Uporabi posnetek poglavij", + "LabelUseFullTrack": "Uporabi celoten posnetek", + "LabelUser": "Uporabnik", + "LabelUsername": "Uporabniško ime", + "LabelValue": "Vrednost", + "LabelVersion": "Verzija", + "LabelViewBookmarks": "Ogled zaznamkov", + "LabelViewChapters": "Ogled poglavij", + "LabelViewPlayerSettings": "Ogled nastavitev predvajalnika", + "LabelViewQueue": "Ogled čakalno vrsto predvajalnika", + "LabelVolume": "Glasnost", + "LabelWeekdaysToRun": "Delovni dnevi predvajanja", + "LabelXBooks": "{0} knjig", + "LabelXItems": "{0} elementov", + "LabelYearReviewHide": "Skrij pregled leta", + "LabelYearReviewShow": "Poglej pregled leta", + "LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig", + "LabelYourBookmarks": "Tvoji zaznamki", + "LabelYourPlaylists": "Tvoje seznami predvajanj", + "LabelYourProgress": "Tvoj napredek", + "MessageAddToPlayerQueue": "Dodaj v čakalno vrsto predvajalnika", + "MessageAppriseDescription": "Če želite uporabljati to funkcijo, morate imeti zagnan primerek API Apprise ali API, ki bo obravnaval te iste zahteve.
Url API-ja Apprise mora biti celotna pot URL-ja za pošiljanje obvestila, npr. če je vaš primerek API-ja postrežen na http://192.168.1.1:8337, bi morali vnesti http://192.168.1.1:8337/notify.", + "MessageBackupsDescription": "Varnostne kopije vključujejo uporabnike, napredek uporabnikov, podrobnosti elementov knjižnice, nastavitve strežnika in slike, shranjene v /metadata/items & /metadata/authors. Varnostne kopije ne vključujejo datotek, shranjenih v mapah vaše knjižnice.", + "MessageBackupsLocationEditNote": "Opomba: Posodabljanje lokacije varnostne kopije ne bo premaknilo ali spremenilo obstoječih varnostnih kopij", + "MessageBackupsLocationNoEditNote": "Opomba: Lokacija varnostne kopije je nastavljena s spremenljivko okolja in je tu ni mogoče spremeniti.", + "MessageBackupsLocationPathEmpty": "Pot do lokacije varnostne kopije ne sme biti prazna", + "MessageBatchQuickMatchDescription": "Hitro ujemanje bo poskušal dodati manjkajoče naslovnice in metapodatke za izbrane elemente. Omogočite spodnje možnosti, da omogočite hitremu ujemanju, da prepiše obstoječe naslovnice in/ali metapodatke.", + "MessageBookshelfNoCollections": "Ustvaril nisi še nobene zbirke", + "MessageBookshelfNoRSSFeeds": "Noben vir RSS ni odprt", + "MessageBookshelfNoResultsForFilter": "Ni rezultatov za filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Ni rezultatov za poizvedbo", + "MessageBookshelfNoSeries": "Nimate serij", + "MessageChapterEndIsAfter": "Konec poglavja je za koncem vaše zvočne knjige", + "MessageChapterErrorFirstNotZero": "Prvo poglavje se mora začeti pri 0", + "MessageChapterErrorStartGteDuration": "Neveljaven začetni čas mora biti krajši od trajanja zvočne knjige", + "MessageChapterErrorStartLtPrev": "Neveljaven začetni čas mora biti večji od ali enak začetnemu času prejšnjega poglavja", + "MessageChapterStartIsAfter": "Začetek poglavja je po koncu vaše zvočne knjige", + "MessageCheckingCron": "Preverjam cron...", + "MessageConfirmCloseFeed": "Ali ste prepričani, da želite zapreti ta vir?", + "MessageConfirmDeleteBackup": "Ali ste prepričani, da želite izbrisati varnostno kopijo za {0}?", + "MessageConfirmDeleteDevice": "Ali ste prepričani, da želite izbrisati e-bralnik \"{0}\"?", + "MessageConfirmDeleteFile": "To bo izbrisalo datoteko iz vašega datotečnega sistema. Ali ste prepričani?", + "MessageConfirmDeleteLibrary": "Ali ste prepričani, da želite trajno izbrisati knjižnico \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "S tem boste element knjižnice izbrisali iz baze podatkov in vašega datotečnega sistema. Ste prepričani?", + "MessageConfirmDeleteLibraryItems": "To bo izbrisalo {0} elementov knjižnice iz baze podatkov in vašega datotečnega sistema. Ste prepričani?", + "MessageConfirmDeleteMetadataProvider": "Ali ste prepričani, da želite izbrisati ponudnika metapodatkov po meri \"{0}\"?", + "MessageConfirmDeleteNotification": "Ali ste prepričani, da želite izbrisati to obvestilo?", + "MessageConfirmDeleteSession": "Ali ste prepričani, da želite izbrisati to sejo?", + "MessageConfirmForceReScan": "Ali ste prepričani, da želite vsiliti ponovno iskanje?", + "MessageConfirmMarkAllEpisodesFinished": "Ali ste prepričani, da želite označiti vse epizode kot dokončane?", + "MessageConfirmMarkAllEpisodesNotFinished": "Ali ste prepričani, da želite vse epizode označiti kot nedokončane?", + "MessageConfirmMarkItemFinished": "Ali ste prepričani, da želite \"{0}\" označiti kot dokončanega?", + "MessageConfirmMarkItemNotFinished": "Ali ste prepričani, da želite \"{0}\" označiti kot nedokončanega?", + "MessageConfirmMarkSeriesFinished": "Ali ste prepričani, da želite vse knjige v tej seriji označiti kot dokončane?", + "MessageConfirmMarkSeriesNotFinished": "Ali ste prepričani, da želite vse knjige v tej seriji označiti kot nedokončane?", + "MessageConfirmNotificationTestTrigger": "Želite sprožiti to obvestilo s testnimi podatki?", "MessageConfirmPurgeCache": "Čiščenje predpomnilnika bo izbrisalo celoten imenik v /metadata/cache.

Ali ste prepričani, da želite odstraniti imenik predpomnilnika?", - "MessageConfirmPurgeItemsCache": "Čiščenje predpomnilnika elementov bo izbrisalo celoten imenik na /metadata/cache/items.
Ste prepričani?" + "MessageConfirmPurgeItemsCache": "Čiščenje predpomnilnika elementov bo izbrisalo celoten imenik na /metadata/cache/items.
Ste prepričani?", + "MessageConfirmQuickEmbed": "Opozorilo! Hitra vdelava ne bo varnostno kopirala vaših zvočnih datotek. Prepričajte se, da imate varnostno kopijo zvočnih datotek.

Ali želite nadaljevati?", + "MessageConfirmReScanLibraryItems": "Ali ste prepričani, da želite ponovno poiskati {0} elementov?", + "MessageConfirmRemoveAllChapters": "Ali ste prepričani, da želite odstraniti vsa poglavja?", + "MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?", + "MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?", + "MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?", + "MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?", + "MessageConfirmRemoveNarrator": "Ali ste prepričani, da želite odstraniti pripovedovalca \"{0}\"?", + "MessageConfirmRemovePlaylist": "Ali ste prepričani, da želite odstraniti svoj seznam predvajanja \"{0}\"?", + "MessageConfirmRenameGenre": "Ali ste prepričani, da želite preimenovati žanr \"{0}\" v \"{1}\" za vse elemente?", + "MessageConfirmRenameGenreMergeNote": "Opomba: Ta žanr že obstaja, zato bosta združeni.", + "MessageConfirmRenameGenreWarning": "Opozorilo! Podoben žanr z različnimi velikosti črk že obstaja \"{0}\".", + "MessageConfirmRenameTag": "Ali ste prepričani, da želite preimenovati oznako \"{0}\" v \"{1}\" za vse elemente?", + "MessageConfirmRenameTagMergeNote": "Opomba: Ta oznaka že obstaja, zato bosta združeni.", + "MessageConfirmRenameTagWarning": "Opozorilo! Podobna oznaka z različnimi velikosti črk že obstaja \"{0}\".", + "MessageConfirmResetProgress": "Ali ste prepričani, da želite ponastaviti svoj napredek?", + "MessageConfirmSendEbookToDevice": "Ali ste prepričani, da želite poslati {0} e-knjigo \"{1}\" v napravo \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Ali ste prepričani, da želite prekiniti povezavo tega uporabnika z OpenID?", + "MessageDownloadingEpisode": "Prenašam epizodo", + "MessageDragFilesIntoTrackOrder": "Povlecite datoteke v pravilen vrstni red posnetkov", + "MessageEmbedFailed": "Vdelava ni uspela!", + "MessageEmbedFinished": "Vdelava končana!", + "MessageEpisodesQueuedForDownload": "{0} epizod v čakalni vrsti za prenos", + "MessageEreaderDevices": "Da zagotovite dostavo e-knjig, boste morda morali dodati zgornji e-poštni naslov kot veljavnega pošiljatelja za vsako spodaj navedeno napravo.", + "MessageFeedURLWillBe": "URL vira bo {0}", + "MessageFetching": "Pridobivam...", + "MessageForceReScanDescription": "bo znova pregledal vse datoteke kot nov pregled. Oznake ID3 zvočnih datotek, datoteke OPF in besedilne datoteke bodo pregledane kot nove.", + "MessageImportantNotice": "Pomembno obvestilo!", + "MessageInsertChapterBelow": "Spodaj vstavite poglavje", + "MessageItemsSelected": "{0} izbranih elementov", + "MessageItemsUpdated": "Št. posodobljenih elementov: {0}", + "MessageJoinUsOn": "Pridružite se nam", + "MessageListeningSessionsInTheLastYear": "{0} sej poslušanja v zadnjem letu", + "MessageLoading": "Nalagam...", + "MessageLoadingFolders": "Nalagam mape...", + "MessageLogsDescription": "Dnevniki so shranjeni v /metadata/logs kot datoteke JSON. Dnevniki zrušitev so shranjeni v /metadata/logs/crash_logs.txt.", + "MessageM4BFailed": "M4B ni uspel!", + "MessageM4BFinished": "M4B končan!", + "MessageMapChapterTitles": "Preslikajte naslove poglavij v obstoječa poglavja zvočne knjige brez prilagajanja časovnih žigov", + "MessageMarkAllEpisodesFinished": "Označi vse epizode kot končane", + "MessageMarkAllEpisodesNotFinished": "Označi vse epizode kot nedokončane", + "MessageMarkAsFinished": "Označi kot dokončano", + "MessageMarkAsNotFinished": "Označi kot nedokončano", + "MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisujejo se pa podrobnosti.", + "MessageNoAudioTracks": "Ni zvočnih posnetkov", + "MessageNoAuthors": "Brez avtorjev", + "MessageNoBackups": "Brez varnostnih kopij", + "MessageNoBookmarks": "Brez zaznamkov", + "MessageNoChapters": "Brez poglavij", + "MessageNoCollections": "Brez zbirk", + "MessageNoCoversFound": "Ni naslovnic", + "MessageNoDescription": "Ni opisa", + "MessageNoDevices": "Ni naprav", + "MessageNoDownloadsInProgress": "Trenutno ni prenosov v teku", + "MessageNoDownloadsQueued": "Ni prenosov v čakalni vrsti", + "MessageNoEpisodeMatchesFound": "Ni zadetkov za epizodo", + "MessageNoEpisodes": "Ni epizod", + "MessageNoFoldersAvailable": "Ni na voljo nobene mape", + "MessageNoGenres": "Ni žanrov", + "MessageNoIssues": "Ni težav", + "MessageNoItems": "Ni elementov", + "MessageNoItemsFound": "Ni najdenih elementov", + "MessageNoListeningSessions": "Ni sej poslušanja", + "MessageNoLogs": "Ni dnevnikov", + "MessageNoMediaProgress": "Ni medijskega napredka", + "MessageNoNotifications": "Ni obvestil", + "MessageNoPodcastsFound": "Ni podcastov", + "MessageNoResults": "Ni rezultatov", + "MessageNoSearchResultsFor": "Ni rezultatov iskanja za \"{0}\"", + "MessageNoSeries": "Ni serij", + "MessageNoTags": "Ni oznak", + "MessageNoTasksRunning": "Nobeno opravili ne teče", + "MessageNoUpdatesWereNecessary": "Posodobitve niso bile potrebne", + "MessageNoUserPlaylists": "Nimate seznamov predvajanja", + "MessageNotYetImplemented": "Še ni implementirano", + "MessageOpmlPreviewNote": "Opomba: To je predogled razčlenjene datoteke OPML. Dejanski naslov podcasta bo vzet iz vira RSS.", + "MessageOr": "ali", + "MessagePauseChapter": "Začasno ustavite predvajanje poglavja", + "MessagePlayChapter": "Poslušajte začetek poglavja", + "MessagePlaylistCreateFromCollection": "Ustvari seznam predvajanja iz zbirke", + "MessagePleaseWait": "Prosim počakajte...", + "MessagePodcastHasNoRSSFeedForMatching": "Podcast nima URL-ja vira RSS, ki bi ga lahko uporabil za ujemanje", + "MessageQuickMatchDescription": "Izpolni prazne podrobnosti elementa in naslovnico s prvim rezultatom ujemanja iz '{0}'. Ne prepiše podrobnosti, razen če je omogočena nastavitev strežnika 'Prednostno ujemajoči se metapodatki'.", + "MessageRemoveChapter": "Odstrani poglavje", + "MessageRemoveEpisodes": "Odstrani toliko epizod: {0}", + "MessageRemoveFromPlayerQueue": "Odstrani iz čakalne vrste predvajalnika", + "MessageRemoveUserWarning": "Ali ste prepričani, da želite trajno izbrisati uporabnika \"{0}\"?", + "MessageReportBugsAndContribute": "Prijavite hrošče, zahtevajte nove funkcije in prispevajte še naprej", + "MessageResetChaptersConfirm": "Ali ste prepričani, da želite ponastaviti poglavja in razveljaviti spremembe, ki ste jih naredili?", + "MessageRestoreBackupConfirm": "Ali ste prepričani, da želite obnoviti varnostno kopijo, ustvarjeno ob", + "MessageRestoreBackupWarning": "Obnovitev varnostne kopije bo prepisala celotno zbirko podatkov, ki se nahaja v /config, in zajema slike v /metadata/items in /metadata/authors.

Varnostne kopije ne spreminjajo nobenih datotek v mapah vaše knjižnice. Če ste omogočili nastavitve strežnika za shranjevanje naslovnic in metapodatkov v mapah vaše knjižnice, potem ti niso varnostno kopirani ali prepisani.

Vsi odjemalci, ki uporabljajo vaš strežnik, bodo samodejno osveženi.", + "MessageSearchResultsFor": "Rezultati iskanja za", + "MessageSelected": "{0} izbrano", + "MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči", + "MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", + "MessageShareExpirationWillBe": "Potečeno bo {0}", + "MessageShareExpiresIn": "Poteče čez {0}", + "MessageShareURLWillBe": "URL za skupno rabo bo {0}", + "MessageStartPlaybackAtTime": "Začni predvajanje za \"{0}\" ob {1}?", + "MessageThinking": "Razmišljam...", + "MessageUploaderItemFailed": "Nalaganje ni uspelo", + "MessageUploaderItemSuccess": "Uspešno naloženo!", + "MessageUploading": "Nalaganje...", + "MessageValidCronExpression": "Veljaven cron izraz", + "MessageWatcherIsDisabledGlobally": "Pregledovalec je globalno onemogočen v nastavitvah strežnika", + "MessageXLibraryIsEmpty": "{0} Knjižnica je prazna!", + "MessageYourAudiobookDurationIsLonger": "Trajanje vaše zvočne knjige je daljše od ugotovljenega trajanja", + "MessageYourAudiobookDurationIsShorter": "Trajanje vaše zvočne knjige je krajše od ugotovljenega trajanja", + "NoteChangeRootPassword": "Korenski uporabnik je edini uporabnik, ki ima lahko prazno geslo", + "NoteChapterEditorTimes": "Opomba: Začetni čas prvega poglavja mora ostati pri 0:00 in zadnji čas začetka poglavja ne sme preseči tega trajanja zvočne knjige.", + "NoteFolderPicker": "Opomba: že preslikane mape ne bodo prikazane", + "NoteRSSFeedPodcastAppsHttps": "Opozorilo: večina aplikacij za podcaste bo zahtevala, da URL vira RSS uporablja HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Opozorilo: 1 ali več vaših epizod nima datuma objave. Nekatere aplikacije za podcaste to zahtevajo.", + "NoteUploaderFoldersWithMediaFiles": "Mape z predstavnostnimi datotekami bodo obravnavane kot ločene postavke knjižnice.", + "NoteUploaderOnlyAudioFiles": "Če nalagate samo zvočne datoteke, bo vsaka zvočna datoteka obravnavana kot ločena zvočna knjiga.", + "NoteUploaderUnsupportedFiles": "Nepodprte datoteke so prezrte. Ko izberete ali spustite mapo, se druge datoteke, ki niso v mapi elementov, prezrejo.", + "PlaceholderNewCollection": "Novo ime zbirke", + "PlaceholderNewFolderPath": "Pot nove mape", + "PlaceholderNewPlaylist": "Novo ime seznama predvajanja", + "PlaceholderSearch": "Poišči..", + "PlaceholderSearchEpisode": "Poišči epizodo...", + "StatsAuthorsAdded": "dodanih avtorjev", + "StatsBooksAdded": "dodanih knjig", + "StatsBooksAdditional": "Nekateri dodatki vključujejo…", + "StatsBooksFinished": "končane knjige", + "StatsBooksFinishedThisYear": "Nekaj knjig, ki so bile dokončane letos…", + "StatsBooksListenedTo": "poslušane knjige", + "StatsCollectionGrewTo": "Vaša zbirka knjig se je povečala na …", + "StatsSessions": "seje", + "StatsSpentListening": "porabil za poslušanje", + "StatsTopAuthor": "TOP AVTOR", + "StatsTopAuthors": "TOP AVTORJI", + "StatsTopGenre": "TOP ŽANR", + "StatsTopGenres": "TOP ŽANRI", + "StatsTopMonth": "TOP MESEC", + "StatsTopNarrator": "TOP PRIPOVEDOVALEC", + "StatsTopNarrators": "TOP PRIPOVEDOVALCI", + "StatsTotalDuration": "S skupnim trajanjem…", + "StatsYearInReview": "PREGLED LETA", + "ToastAccountUpdateFailed": "Računa ni bilo mogoče posodobiti", + "ToastAccountUpdateSuccess": "Račun posodobljen", + "ToastAppriseUrlRequired": "Vnesti morate Apprise URL", + "ToastAuthorImageRemoveSuccess": "Slika avtorja je odstranjena", + "ToastAuthorNotFound": "Avtor \"{0}\" ni bil najden", + "ToastAuthorRemoveSuccess": "Avtor odstranjen", + "ToastAuthorSearchNotFound": "Ne najdem avtorja", + "ToastAuthorUpdateFailed": "Avtorja ni bilo mogoče posodobiti", + "ToastAuthorUpdateMerged": "Avtor združen", + "ToastAuthorUpdateSuccess": "Avtor posodobljen", + "ToastAuthorUpdateSuccessNoImageFound": "Avtor posodobljen (ne najdem slike)", + "ToastBackupAppliedSuccess": "Uporabljena varnostna kopija", + "ToastBackupCreateFailed": "Varnostne kopije ni bilo mogoče ustvariti", + "ToastBackupCreateSuccess": "Varnostna kopija ustvarjena", + "ToastBackupDeleteFailed": "Varnostne kopije ni bilo mogoče izbrisati", + "ToastBackupDeleteSuccess": "Varnostna kopija izbrisana", + "ToastBackupInvalidMaxKeep": "Neveljavno število varnostnih kopij za ohranjanje", + "ToastBackupInvalidMaxSize": "Neveljavna največja velikost varnostne kopije", + "ToastBackupPathUpdateFailed": "Posodobitev poti varnostnih kopij ni uspela", + "ToastBackupRestoreFailed": "Varnostne kopije ni bilo mogoče obnoviti", + "ToastBackupUploadFailed": "Nalaganje varnostne kopije ni uspelo", + "ToastBackupUploadSuccess": "Varnostna kopija je naložena", + "ToastBatchDeleteFailed": "Paketno brisanje ni uspelo", + "ToastBatchDeleteSuccess": "Paketno brisanje je bilo uspešno", + "ToastBatchUpdateFailed": "Paketna posodobitev ni uspela", + "ToastBatchUpdateSuccess": "Paketna posodobitev je uspela", + "ToastBookmarkCreateFailed": "Zaznamka ni bilo mogoče ustvariti", + "ToastBookmarkCreateSuccess": "Zaznamek dodan", + "ToastBookmarkRemoveSuccess": "Zaznamek odstranjen", + "ToastBookmarkUpdateFailed": "Zaznamka ni bilo mogoče posodobiti", + "ToastBookmarkUpdateSuccess": "Zaznamek posodobljen", + "ToastCachePurgeFailed": "Čiščenje predpomnilnika ni uspelo", + "ToastCachePurgeSuccess": "Predpomnilnik je bil uspešno očiščen", + "ToastChaptersHaveErrors": "Poglavja imajo napake", + "ToastChaptersMustHaveTitles": "Poglavja morajo imeti naslove", + "ToastChaptersRemoved": "Poglavja so odstranjena", + "ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo", + "ToastCollectionItemsAddSuccess": "Dodajanje elementov v zbirko je bilo uspešno", + "ToastCollectionItemsRemoveSuccess": "Elementi so bili odstranjeni iz zbirke", + "ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena", + "ToastCollectionUpdateFailed": "Zbirke ni bilo mogoče posodobiti", + "ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena", + "ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela", + "ToastDeleteFileFailed": "Brisanje datoteke ni uspelo", + "ToastDeleteFileSuccess": "Datoteka je bila izbrisana", + "ToastDeviceAddFailed": "Naprave ni bilo mogoče dodati", + "ToastDeviceNameAlreadyExists": "Elektronska naprava s tem imenom že obstaja", + "ToastDeviceTestEmailFailed": "Pošiljanje testnega e-poštnega sporočila ni uspelo", + "ToastDeviceTestEmailSuccess": "Testno e-poštno sporočilo je poslano", + "ToastDeviceUpdateFailed": "Naprave ni bilo mogoče posodobiti", + "ToastEmailSettingsUpdateFailed": "E-poštnih nastavitev ni bilo mogoče posodobiti", + "ToastEmailSettingsUpdateSuccess": "E-poštne nastavitve so bile posodobljene", + "ToastEncodeCancelFailed": "Napaka pri preklicu prekodiranja", + "ToastEncodeCancelSucces": "Prekodiranje prekinjeno", + "ToastEpisodeDownloadQueueClearFailed": "Čiščenje čakalne vrste ni uspelo", + "ToastEpisodeDownloadQueueClearSuccess": "Čakalna vrsta za prenos epizod je počiščena", + "ToastErrorCannotShare": "V tej napravi ni mogoče dati v skupno rabo", + "ToastFailedToLoadData": "Podatkov ni bilo mogoče naložiti", + "ToastFailedToShare": "Skupna raba ni uspela", + "ToastFailedToUpdateAccount": "Računa ni bilo mogoče posodobiti", + "ToastFailedToUpdateUser": "Uporabnika ni bilo mogoče posodobiti", + "ToastInvalidImageUrl": "Neveljaven URL slike", + "ToastInvalidUrl": "Neveljaven URL", + "ToastItemCoverUpdateFailed": "Naslovnice elementa ni bilo mogoče posodobiti", + "ToastItemCoverUpdateSuccess": "Naslovnica elementa je bila posodobljena", + "ToastItemDeletedFailed": "Elementa ni bilo mogoče izbrisati", + "ToastItemDeletedSuccess": "Element je bil izbrisan", + "ToastItemDetailsUpdateFailed": "Posodobitev podrobnosti elementa ni uspela", + "ToastItemDetailsUpdateSuccess": "Podrobnosti elementa so bile posodobjene", + "ToastItemMarkedAsFinishedFailed": "Označevanje kot dokončano ni uspelo", + "ToastItemMarkedAsFinishedSuccess": "Element je označen kot dokončan", + "ToastItemMarkedAsNotFinishedFailed": "Ni bilo mogoče označiti kot nedokončano", + "ToastItemMarkedAsNotFinishedSuccess": "Element označen kot nedokončan", + "ToastItemUpdateFailed": "Elementa ni bilo mogoče posodobiti", + "ToastItemUpdateSuccess": "Element je bil posodobljen", + "ToastLibraryCreateFailed": "Knjižnice ni bilo mogoče ustvariti", + "ToastLibraryCreateSuccess": "Knjižnica \"{0}\" je bila ustvarjena", + "ToastLibraryDeleteFailed": "Knjižnice ni bilo mogoče izbrisati", + "ToastLibraryDeleteSuccess": "Knjižnica je bila izbrisana", + "ToastLibraryScanFailedToStart": "Pregleda ni bilo mogoče začeti", + "ToastLibraryScanStarted": "Pregled knjižnice se je začel", + "ToastLibraryUpdateFailed": "Knjižnice ni bilo mogoče posodobiti", + "ToastLibraryUpdateSuccess": "Knjižnica \"{0}\" je bila posodobljena", + "ToastNameEmailRequired": "Ime in e-pošta sta obvezna", + "ToastNameRequired": "Ime je obvezno", + "ToastNewUserCreatedFailed": "Računa ni bilo mogoče ustvariti: \"{0}\"", + "ToastNewUserCreatedSuccess": "Nov račun je bil ustvarjen", + "ToastNewUserLibraryError": "Izbrati morate vsaj eno knjižnico", + "ToastNewUserPasswordError": "Mora imeti geslo, samo korenski uporabnik ima lahko prazno geslo", + "ToastNewUserTagError": "Izbrati morate vsaj eno oznako", + "ToastNewUserUsernameError": "Vnesite uporabniško ime", + "ToastNoUpdatesNecessary": "Posodobitve niso potrebne", + "ToastNotificationCreateFailed": "Obvestila ni bilo mogoče ustvariti", + "ToastNotificationDeleteFailed": "Brisanje obvestila ni uspelo", + "ToastNotificationFailedMaximum": "Največje število neuspelih poskusov mora biti >= 0", + "ToastNotificationQueueMaximum": "Največja čakalna vrsta obvestil mora biti >= 0", + "ToastNotificationSettingsUpdateFailed": "Nastavitev obvestil ni bilo mogoče posodobiti", + "ToastNotificationSettingsUpdateSuccess": "Nastavitve obvestil so bile posodobljene", + "ToastNotificationTestTriggerFailed": "Sprožitev testnega obvestila ni uspela", + "ToastNotificationTestTriggerSuccess": "Sproženo testno obvestilo", + "ToastNotificationUpdateFailed": "Obvestila ni bilo mogoče posodobiti", + "ToastNotificationUpdateSuccess": "Obvestilo posodobljeno", + "ToastPlaylistCreateFailed": "Seznama predvajanja ni bilo mogoče ustvariti", + "ToastPlaylistCreateSuccess": "Seznam predvajanja je bil ustvarjen", + "ToastPlaylistRemoveSuccess": "Seznam predvajanja odstranjen", + "ToastPlaylistUpdateFailed": "Seznama predvajanja ni bilo mogoče posodobiti", + "ToastPlaylistUpdateSuccess": "Seznam predvajanja je bil posodobljen", + "ToastPodcastCreateFailed": "Podcasta ni bilo mogoče ustvariti", + "ToastPodcastCreateSuccess": "Podcast je bil uspešno ustvarjen", + "ToastPodcastGetFeedFailed": "Vira podcasta ni bilo mogoče pridobiti", + "ToastPodcastNoEpisodesInFeed": "V viru RSS ni bilo mogoče najti nobene epizode", + "ToastPodcastNoRssFeed": "Podcast nima vira RSS", + "ToastProviderCreatedFailed": "Ponudnika ni bilo mogoče dodati", + "ToastProviderCreatedSuccess": "Dodan je bil nov ponudnik", + "ToastProviderNameAndUrlRequired": "Obvezen podatek sta ime in URL", + "ToastProviderRemoveSuccess": "Ponudnik je bil odstranjen", + "ToastRSSFeedCloseFailed": "Vira RSS ni bilo mogoče zapreti", + "ToastRSSFeedCloseSuccess": "Vir RSS je bil zaprt", + "ToastRemoveFailed": "Odstranitev ni uspela", + "ToastRemoveItemFromCollectionFailed": "Elementa ni bilo mogoče odstraniti iz zbirke", + "ToastRemoveItemFromCollectionSuccess": "Element je bil odstranjen iz zbirke", + "ToastRemoveItemsWithIssuesFailed": "Elementov knjižnice s težavami ni bilo mogoče odstraniti", + "ToastRemoveItemsWithIssuesSuccess": "Odstranjeni so bili elementi knjižnice s težavami", + "ToastRenameFailed": "Preimenovanje ni uspelo", + "ToastRescanFailed": "Ponovni pregled ni uspel za {0}", + "ToastRescanRemoved": "Ponovni pregled celotnega elementa je bil odstranjen", + "ToastRescanUpToDate": "Ponovni pregled celotnega elementa je bil ažuren", + "ToastRescanUpdated": "Ponovni pregled celotnega elementa je bil posodobljen", + "ToastScanFailed": "Pregled elementa knjižnice ni uspel", + "ToastSelectAtLeastOneUser": "Izberite vsaj enega uporabnika", + "ToastSendEbookToDeviceFailed": "E-knjige ni bilo mogoče poslati v napravo", + "ToastSendEbookToDeviceSuccess": "E-knjiga je bila poslana v napravo \"{0}\"", + "ToastSeriesUpdateFailed": "Posodobitev serije ni uspela", + "ToastSeriesUpdateSuccess": "Uspešna posodobitev serije", + "ToastServerSettingsUpdateFailed": "Nastavitev strežnika ni bilo mogoče posodobiti", + "ToastServerSettingsUpdateSuccess": "Nastavitve strežnika so bile posodobljene", + "ToastSessionCloseFailed": "Seje ni bilo mogoče zapreti", + "ToastSessionDeleteFailed": "Brisanje seje ni uspelo", + "ToastSessionDeleteSuccess": "Seja je bila izbrisana", + "ToastSlugMustChange": "Slug vsebuje neveljavne znake", + "ToastSlugRequired": "Slug je obvezen podatek", + "ToastSocketConnected": "Omrežna povezava je priklopljena", + "ToastSocketDisconnected": "Omrežna povezava je odklopljena", + "ToastSocketFailedToConnect": "Omrežna povezava ni uspela vzpostaviti priklopa", + "ToastSortingPrefixesEmptyError": "Imeti mora vsaj 1 predpono za razvrščanje", + "ToastSortingPrefixesUpdateFailed": "Posodobitev predpon za razvrščanje ni uspela", + "ToastSortingPrefixesUpdateSuccess": "Predpone za razvrščanje so bile posodobljene ({0} elementov)", + "ToastTitleRequired": "Naslov je obvezen", + "ToastUnknownError": "Neznana napaka", + "ToastUnlinkOpenIdFailed": "Prekinitev povezave uporabnika z OpenID ni uspela", + "ToastUnlinkOpenIdSuccess": "Uporabnik je prekinil povezavo z OpenID", + "ToastUserDeleteFailed": "Brisanje uporabnika ni uspelo", + "ToastUserDeleteSuccess": "Uporabnik je bil izbrisan", + "ToastUserPasswordChangeSuccess": "Geslo je bilo uspešno spremenjeno", + "ToastUserPasswordMismatch": "Gesli se ne ujemata", + "ToastUserPasswordMustChange": "Novo geslo se ne sme ujemati s starim geslom", + "ToastUserRootRequireName": "Vnesti morate korensko uporabniško ime" } From d0b3726905685018389705a75aefdd854ef9abeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Pomyka=C5=82a?= Date: Mon, 2 Sep 2024 13:20:50 +0000 Subject: [PATCH 061/539] Translated using Weblate (Polish) Currently translated at 81.8% (797 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/client/strings/pl.json b/client/strings/pl.json index 2c7917a564..b156c71dc4 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Wybierz pliki", "ButtonClearFilter": "Wyczyść filtr", "ButtonCloseFeed": "Zamknij kanał", + "ButtonCloseSession": "Zamknij otwartą sesję", "ButtonCollections": "Kolekcje", "ButtonConfigureScanner": "Skonfiguruj skaner", "ButtonCreate": "Utwórz", @@ -28,6 +29,7 @@ "ButtonEdit": "Edycja", "ButtonEditChapters": "Edytuj rozdziały", "ButtonEditPodcast": "Edytuj podcast", + "ButtonEnable": "Włącz", "ButtonForceReScan": "Wymuś ponowne skanowanie", "ButtonFullPath": "Pełna ścieżka", "ButtonHide": "Ukryj", @@ -47,8 +49,10 @@ "ButtonNext": "Następny", "ButtonNextChapter": "Następny rozdział", "ButtonNextItemInQueue": "Następny element w kolejce", + "ButtonOk": "Ok", "ButtonOpenFeed": "Otwórz feed", "ButtonOpenManager": "Otwórz menadżera", + "ButtonPause": "Wstrzymaj", "ButtonPlay": "Odtwarzaj", "ButtonPlaying": "Odtwarzane", "ButtonPlaylists": "Listy odtwarzania", @@ -90,6 +94,8 @@ "ButtonStartMetadataEmbed": "Osadź metadane", "ButtonStats": "Statystyki", "ButtonSubmit": "Zaloguj", + "ButtonTest": "Test", + "ButtonUnlinkOpedId": "Odłącz OpenID", "ButtonUpload": "Wgraj", "ButtonUploadBackup": "Wgraj kopię zapasową", "ButtonUploadCover": "Wgraj okładkę", @@ -102,6 +108,7 @@ "ErrorUploadFetchMetadataNoResults": "Nie można pobrać metadanych — spróbuj zaktualizować tytuł i/lub autora", "ErrorUploadLacksTitle": "Musi mieć tytuł", "HeaderAccount": "Konto", + "HeaderAddCustomMetadataProvider": "Dodaj niestandardowego dostawcę metadanych", "HeaderAdvanced": "Zaawansowane", "HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise", "HeaderAudioTracks": "Ścieżki audio", @@ -120,6 +127,7 @@ "HeaderDetails": "Szczegóły", "HeaderDownloadQueue": "Kolejka do ściągania", "HeaderEbookFiles": "Pliki Ebook", + "HeaderEmail": "E-mail", "HeaderEmailSettings": "Ustawienia e-mail", "HeaderEpisodes": "Rozdziały", "HeaderEreaderDevices": "Czytniki", @@ -145,6 +153,8 @@ "HeaderMetadataToEmbed": "Osadź metadane", "HeaderNewAccount": "Nowe konto", "HeaderNewLibrary": "Nowa biblioteka", + "HeaderNotificationCreate": "Utwórz powiadomienie", + "HeaderNotificationUpdate": "Zaktualizuj powiadomienie", "HeaderNotifications": "Powiadomienia", "HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect", "HeaderOpenRSSFeed": "Utwórz kanał RSS", @@ -157,7 +167,9 @@ "HeaderPlaylistItems": "Pozycje listy odtwarzania", "HeaderPodcastsToAdd": "Podcasty do dodania", "HeaderPreviewCover": "Podgląd okładki", + "HeaderRSSFeedGeneral": "Szczegóły RSS", "HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty", + "HeaderRSSFeeds": "Kanały RSS", "HeaderRemoveEpisode": "Usuń odcinek", "HeaderRemoveEpisodes": "Usuń {0} odcinków", "HeaderSavedMediaProgress": "Zapisany postęp", @@ -200,6 +212,7 @@ "LabelAddToPlaylist": "Dodaj do playlisty", "LabelAddToPlaylistBatch": "Dodaj {0} pozycji do playlisty", "LabelAddedAt": "Dodano", + "LabelAddedDate": "Dodano {0}", "LabelAdminUsersOnly": "Tylko użytkownicy administracyjni", "LabelAll": "Wszystkie", "LabelAllUsers": "Wszyscy użytkownicy", @@ -215,15 +228,19 @@ "LabelAutoFetchMetadata": "Automatycznie pobierz metadane", "LabelAutoFetchMetadataHelp": "Pobiera metadane dotyczące tytułu, autora i serii, aby usprawnić przesyłanie. Po przesłaniu może być konieczne dopasowanie dodatkowych metadanych.", "LabelAutoLaunch": "Uruchom automatycznie", + "LabelAutoRegister": "Automatyczna rejestracja", + "LabelAutoRegisterDescription": "Automatycznie utwórz nowych użytkowników po zalogowaniu", "LabelBackToUser": "Powrót", "LabelBackupLocation": "Lokalizacja kopii zapasowej", "LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe", "LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups", - "LabelBackupsMaxBackupSize": "Maksymalny rozmiar kopii zapasowej (w GB)", + "LabelBackupsMaxBackupSize": "Maksymalny rozmiar kopii zapasowej (w GB) (0 oznacza nieograniczony)", "LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.", "LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania", "LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.", + "LabelBitrate": "Bitrate", "LabelBooks": "Książki", + "LabelButtonText": "Tekst przycisku", "LabelByAuthor": "autorstwa {0}", "LabelChangePassword": "Zmień hasło", "LabelChannels": "Kanały", @@ -232,6 +249,7 @@ "LabelChaptersFound": "Znalezione rozdziały", "LabelClickForMoreInfo": "Kliknij po więcej szczegółów", "LabelClosePlayer": "Zamknij odtwarzacz", + "LabelCodec": "Kodek", "LabelCollapseSeries": "Podsumuj serię", "LabelCollapseSubSeries": "Zwiń podserie", "LabelCollection": "Kolekcja", @@ -247,6 +265,7 @@ "LabelCronExpression": "Wyrażenie CRON", "LabelCurrent": "Aktualny", "LabelCurrently": "Obecnie:", + "LabelCustomCronExpression": "Niestandardowe wyrażenie Cron:", "LabelDatetime": "Data i godzina", "LabelDays": "Dni", "LabelDeleteFromFileSystemCheckbox": "Usuń z systemu plików (odznacz, aby usunąć tylko z bazy danych)", @@ -254,6 +273,7 @@ "LabelDeselectAll": "Odznacz wszystko", "LabelDevice": "Urządzenie", "LabelDeviceInfo": "Informacja o urządzeniu", + "LabelDeviceIsAvailableTo": "Urządzenie jest dostępne do...", "LabelDirectory": "Katalog", "LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku", "LabelDiscFromMetadata": "Oznaczenie dysku z metadanych", @@ -261,16 +281,20 @@ "LabelDownload": "Pobierz", "LabelDownloadNEpisodes": "Ściąganie {0} odcinków", "LabelDuration": "Czas trwania", + "LabelDurationComparisonExactMatch": "(dokładne dopasowanie)", "LabelDurationComparisonLonger": "({0} dłużej)", "LabelDurationComparisonShorter": "({0} krócej)", "LabelDurationFound": "Znaleziona długość:", + "LabelEbook": "Ebook", "LabelEbooks": "Ebooki", "LabelEdit": "Edytuj", + "LabelEmail": "E-mail", "LabelEmailSettingsFromAddress": "Z adresu", "LabelEmailSettingsRejectUnauthorized": "Odrzuć nieautoryzowane certyfikaty", "LabelEmailSettingsRejectUnauthorizedHelp": "Wyłączenie walidacji certyfikatów SSL może narazić cię na ryzyka bezpieczeństwa, takie jak ataki man-in-the-middle. Wyłącz tą opcję wyłącznie jeśli rozumiesz tego skutki i ufasz serwerowi pocztowemu, do którego się podłączasz.", "LabelEmailSettingsSecure": "Bezpieczeństwo", "LabelEmailSettingsSecureHelp": "Jeśli włączysz, połączenie będzie korzystać z TLS podczas łączenia do serwera. Jeśli wyłączysz, TLS będzie wykorzystane jeśli serwer wspiera rozszerzenie STARTTLS. W większości przypadków włącz to ustawienie jeśli łączysz się do portu 465. Dla portów 587 lub 25 pozostaw to ustawienie wyłączone. (na podstawie nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Adres testowy", "LabelEmbeddedCover": "Wbudowana okładka", "LabelEnable": "Włącz", "LabelEnd": "Zakończ", @@ -278,16 +302,21 @@ "LabelEpisode": "Odcinek", "LabelEpisodeTitle": "Tytuł odcinka", "LabelEpisodeType": "Typ odcinka", + "LabelEpisodes": "Epizody", "LabelExample": "Przykład", "LabelExpandSeries": "Rozwiń serie", "LabelExpandSubSeries": "Rozwiń podserie", "LabelExplicit": "Nieprzyzwoite", + "LabelExplicitChecked": "Nieprzyzwoite (sprawdzone)", + "LabelExplicitUnchecked": "Przyzwoite (niesprawdzone)", "LabelExportOPML": "Wyeksportuj OPML", "LabelFeedURL": "URL kanału", "LabelFetchingMetadata": "Pobieranie metadanych", "LabelFile": "Plik", "LabelFileBirthtime": "Data utworzenia pliku", + "LabelFileBornDate": "Utworzony {0}", "LabelFileModified": "Data modyfikacji pliku", + "LabelFileModifiedDate": "Modyfikowany {0}", "LabelFilename": "Nazwa pliku", "LabelFilterByUser": "Filtruj według danego użytkownika", "LabelFindEpisodes": "Znajdź odcinki", @@ -297,8 +326,10 @@ "LabelFontBold": "Pogrubiony", "LabelFontBoldness": "Grubość czcionki", "LabelFontFamily": "Rodzina czcionek", + "LabelFontItalic": "Kursywa", "LabelFontScale": "Rozmiar czcionki", "LabelFontStrikethrough": "Przekreślony", + "LabelFormat": "Format", "LabelGenre": "Gatunek", "LabelGenres": "Gatunki", "LabelHardDeleteFile": "Usuń trwale plik", @@ -306,6 +337,7 @@ "LabelHasSupplementaryEbook": "Posiada dodatkowy ebook", "LabelHideSubtitles": "Ukryj napisy", "LabelHighestPriority": "Najwyższy priorytet", + "LabelHost": "Host", "LabelHour": "Godzina", "LabelHours": "Godziny", "LabelIcon": "Ikona", @@ -324,6 +356,7 @@ "LabelIntervalEveryHour": "Każdej godziny", "LabelInvert": "Inversja", "LabelItem": "Pozycja", + "LabelJumpBackwardAmount": "Rozmiar skoku do przodu", "LabelLanguage": "Język", "LabelLanguageDefaultServer": "Domyślny język serwera", "LabelLanguages": "Języki", @@ -338,10 +371,13 @@ "LabelLess": "Mniej", "LabelLibrariesAccessibleToUser": "Biblioteki dostępne dla użytkownika", "LabelLibrary": "Biblioteka", + "LabelLibraryFilterSublistEmpty": "Brak {0}", "LabelLibraryItem": "Element biblioteki", "LabelLibraryName": "Nazwa biblioteki", + "LabelLimit": "Limit", "LabelLineSpacing": "Odstęp między wierszami", "LabelListenAgain": "Słuchaj ponownie", + "LabelLogLevelDebug": "Debugowanie", "LabelLogLevelInfo": "Informacja", "LabelLogLevelWarn": "Ostrzeżenie", "LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie", @@ -351,6 +387,7 @@ "LabelMediaPlayer": "Odtwarzacz", "LabelMediaType": "Typ mediów", "LabelMetaTag": "Tag", + "LabelMetaTags": "Meta Tagi", "LabelMetadataOrderOfPrecedenceDescription": "Źródła metadanych o wyższym priorytecie będą zastępują źródła o niższym priorytecie", "LabelMetadataProvider": "Dostawca metadanych", "LabelMinute": "Minuta", @@ -358,6 +395,7 @@ "LabelMissing": "Brakujący", "LabelMissingEbook": "Nie posiada ebooka", "LabelMissingSupplementaryEbook": "Nie posiada dodatkowego ebooka", + "LabelMobileRedirectURIs": "Dozwolone URI przekierowań mobilnych", "LabelMore": "Więcej", "LabelMoreInfo": "Więcej informacji", "LabelName": "Nazwa", From 53b5bee7366aed743f082791a7f24e2a50595fb6 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 2 Sep 2024 12:38:46 +0000 Subject: [PATCH 062/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 44 +++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index e71f31dc46..f8954e3a36 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -5,7 +5,7 @@ "ButtonAddLibrary": "Dodaj knjižnico", "ButtonAddPodcasts": "Dodaj podcast", "ButtonAddUser": "Dodaj uporabnika", - "ButtonAddYourFirstLibrary": "Dodaj tvojo prvo knjižnico", + "ButtonAddYourFirstLibrary": "Dodajte svojo prvo knjižnico", "ButtonApply": "Uveljavi", "ButtonApplyChapters": "Uveljavi poglavja", "ButtonAuthors": "Avtorji", @@ -15,13 +15,13 @@ "ButtonCancelEncode": "Prekliči prekodiranje", "ButtonChangeRootPassword": "Zamenjaj korensko geslo", "ButtonCheckAndDownloadNewEpisodes": "Preveri in prenesi nove epizode", - "ButtonChooseAFolder": "Izberi mapo", - "ButtonChooseFiles": "Izberi datoteke", + "ButtonChooseAFolder": "Izberite mapo", + "ButtonChooseFiles": "Izberite datoteke", "ButtonClearFilter": "Počisti filter", "ButtonCloseFeed": "Zapri vir", "ButtonCloseSession": "Zapri odprto sejo", "ButtonCollections": "Zbirke", - "ButtonConfigureScanner": "Nastavi skener", + "ButtonConfigureScanner": "Nastavi pregledovalnik", "ButtonCreate": "Ustvari", "ButtonCreateBackup": "Ustvari varnostno kopijo", "ButtonDelete": "Izbriši", @@ -30,9 +30,9 @@ "ButtonEditChapters": "Uredi poglavja", "ButtonEditPodcast": "Uredi podcast", "ButtonEnable": "Omogoči", - "ButtonFireAndFail": "Zaženi in je neuspešno", + "ButtonFireAndFail": "Zaženi in je bilo neuspešno", "ButtonFireOnTest": "Zaženi samo na dogodku onTest", - "ButtonForceReScan": "Prisilno ponovno skeniranje", + "ButtonForceReScan": "Prisilno ponovno pregledovanje", "ButtonFullPath": "Polna pot", "ButtonHide": "Skrij", "ButtonHome": "Domov", @@ -67,7 +67,7 @@ "ButtonQueueRemoveItem": "Odstrani iz čakalne vrste", "ButtonQuickEmbedMetadata": "Hitra vdelava metapodatkov", "ButtonQuickMatch": "Hitro ujemanje", - "ButtonReScan": "Ponovno iskanje", + "ButtonReScan": "Ponovno pregledovanje", "ButtonRead": "Preberi", "ButtonReadLess": "Preberi manj", "ButtonReadMore": "Preberi več", @@ -84,10 +84,10 @@ "ButtonSave": "Shrani", "ButtonSaveAndClose": "Shrani iz zapri", "ButtonSaveTracklist": "Shrani seznam skladb", - "ButtonScan": "Skeniranje", - "ButtonScanLibrary": "Skeniraj knjižnico", + "ButtonScan": "Pregledovanje", + "ButtonScanLibrary": "Preglej knjižnico", "ButtonSearch": "Poišči", - "ButtonSelectFolderPath": "Izberi pot mape", + "ButtonSelectFolderPath": "Izberite pot do mape", "ButtonSeries": "Serije", "ButtonSetChaptersFromTracks": "Nastavi poglavja za posnetke", "ButtonShare": "Deli", @@ -120,7 +120,7 @@ "HeaderBackups": "Varnostne kopije", "HeaderChangePassword": "Zamenjaj geslo", "HeaderChapters": "Poglavja", - "HeaderChooseAFolder": "Izberi mapo", + "HeaderChooseAFolder": "Izberite mapo", "HeaderCollection": "Zbirka", "HeaderCollectionItems": "Elementi zbirke", "HeaderCover": "Naslovnica", @@ -178,7 +178,7 @@ "HeaderRemoveEpisodes": "Odstrani {0} epizod", "HeaderSavedMediaProgress": "Shranjen napredek predstavnosti", "HeaderSchedule": "Načrtovanje", - "HeaderScheduleLibraryScans": "Načrtuj samodejno skeniranje knjižnice", + "HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice", "HeaderSession": "Seja", "HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja", "HeaderSettings": "Nastavitve", @@ -189,7 +189,7 @@ "HeaderSleepTimer": "Časovnik za izklop", "HeaderStatsLargestItems": "Največji elementi", "HeaderStatsLongestItems": "Najdaljši elementi (ure)", - "HeaderStatsMinutesListeningChart": "Minute poslušanja (zadnjih 7 dni)", + "HeaderStatsMinutesListeningChart": "Minut poslušanja (zadnjih 7 dni)", "HeaderStatsRecentSessions": "Nedavne seje", "HeaderStatsTop10Authors": "Najboljših 10 avtorjev", "HeaderStatsTop5Genres": "Najboljših 5 žanrov", @@ -406,8 +406,8 @@ "LabelMore": "Več", "LabelMoreInfo": "Več informacij", "LabelName": "Naziv", - "LabelNarrator": "Pripovedovalec", - "LabelNarrators": "Pripovedovalci", + "LabelNarrator": "Bralec", + "LabelNarrators": "Bralci", "LabelNew": "Novo", "LabelNewPassword": "Novo geslo", "LabelNewestAuthors": "Najnovejši avtorji", @@ -489,10 +489,10 @@ "LabelSearchTitle": "Naslov iskanja", "LabelSearchTitleOrASIN": "Naslov iskanja ali ASIN", "LabelSeason": "Sezona", - "LabelSelectAll": "Izberi vse", - "LabelSelectAllEpisodes": "Izberi vse epizode", + "LabelSelectAll": "Izberite vse", + "LabelSelectAllEpisodes": "Izberite vse epizode", "LabelSelectEpisodesShowing": "Izberi {0} prikazanih epizod", - "LabelSelectUsers": "Izberi uporabnike", + "LabelSelectUsers": "Izberite uporabnike", "LabelSendEbookToDevice": "Pošlji eknjigo k...", "LabelSequence": "Zaporedje", "LabelSeries": "Serije", @@ -517,7 +517,7 @@ "LabelSettingsExperimentalFeatures": "Eksperimentalne funkcije", "LabelSettingsExperimentalFeaturesHelp": "Funkcije v razvoju, ki bi lahko uporabile vaše povratne informacije in pomoč pri testiranju. Kliknite, da odprete razpravo na githubu.", "LabelSettingsFindCovers": "Poišči naslovnice", - "LabelSettingsFindCoversHelp": "Če vaša zvočna knjiga nima vdelane naslovnice ali slike naslovnice v mapi, bo optični bralnik poskušal najti naslovnico.
Opomba: To bo podaljšalo čas skeniranja", + "LabelSettingsFindCoversHelp": "Če vaša zvočna knjiga nima vdelane naslovnice ali slike naslovnice v mapi, bo pregledovalnik poskušal najti naslovnico.
Opomba: To bo podaljšalo čas pregledovanja", "LabelSettingsHideSingleBookSeries": "Skrij serije s samo eno knjigo", "LabelSettingsHideSingleBookSeriesHelp": "Serije, ki imajo eno knjigo, bodo skrite na strani serije in policah domače strani.", "LabelSettingsHomePageBookshelfView": "Domača stran bo imela pogled knjižne police", @@ -684,7 +684,7 @@ "MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?", "MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?", "MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?", - "MessageConfirmRemoveNarrator": "Ali ste prepričani, da želite odstraniti pripovedovalca \"{0}\"?", + "MessageConfirmRemoveNarrator": "Ali ste prepričani, da želite odstraniti bralca \"{0}\"?", "MessageConfirmRemovePlaylist": "Ali ste prepričani, da želite odstraniti svoj seznam predvajanja \"{0}\"?", "MessageConfirmRenameGenre": "Ali ste prepričani, da želite preimenovati žanr \"{0}\" v \"{1}\" za vse elemente?", "MessageConfirmRenameGenreMergeNote": "Opomba: Ta žanr že obstaja, zato bosta združeni.", @@ -812,8 +812,8 @@ "StatsTopGenre": "TOP ŽANR", "StatsTopGenres": "TOP ŽANRI", "StatsTopMonth": "TOP MESEC", - "StatsTopNarrator": "TOP PRIPOVEDOVALEC", - "StatsTopNarrators": "TOP PRIPOVEDOVALCI", + "StatsTopNarrator": "TOP BRALEC", + "StatsTopNarrators": "TOP BRALCI", "StatsTotalDuration": "S skupnim trajanjem…", "StatsYearInReview": "PREGLED LETA", "ToastAccountUpdateFailed": "Računa ni bilo mogoče posodobiti", From f9f7fbed33a771c13d31ca029b612c86e00b8714 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Mon, 2 Sep 2024 21:15:33 +0000 Subject: [PATCH 063/539] Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ --- client/strings/bn.json | 1 - client/strings/de.json | 1 - client/strings/es.json | 1 - client/strings/fr.json | 1 - client/strings/hr.json | 1 - client/strings/pl.json | 1 - client/strings/ru.json | 1 - client/strings/sl.json | 1 - 8 files changed, 8 deletions(-) diff --git a/client/strings/bn.json b/client/strings/bn.json index d0fddf9e78..9334213583 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -98,7 +98,6 @@ "ButtonStats": "পরিসংখ্যান", "ButtonSubmit": "জমা দিন", "ButtonTest": "পরীক্ষা", - "ButtonUnlinkOpedId": "ওপেন আইডি লিংকমুক্ত করুন", "ButtonUpload": "আপলোড", "ButtonUploadBackup": "আপলোড ব্যাকআপ", "ButtonUploadCover": "কভার আপলোড করুন", diff --git a/client/strings/de.json b/client/strings/de.json index bf7f21739a..b7eed8b972 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -98,7 +98,6 @@ "ButtonStats": "Statistiken", "ButtonSubmit": "Ok", "ButtonTest": "Test", - "ButtonUnlinkOpedId": "OpenID trennen", "ButtonUpload": "Hochladen", "ButtonUploadBackup": "Sicherung hochladen", "ButtonUploadCover": "Titelbild hochladen", diff --git a/client/strings/es.json b/client/strings/es.json index d7b7a1aed9..59ba6c3f69 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -97,7 +97,6 @@ "ButtonStats": "Estadísticas", "ButtonSubmit": "Enviar", "ButtonTest": "Prueba", - "ButtonUnlinkOpedId": "Desvincular OpenID", "ButtonUpload": "Subir", "ButtonUploadBackup": "Subir Respaldo", "ButtonUploadCover": "Subir Portada", diff --git a/client/strings/fr.json b/client/strings/fr.json index b107fe629b..6b01995a9b 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -98,7 +98,6 @@ "ButtonStats": "Statistiques", "ButtonSubmit": "Soumettre", "ButtonTest": "Test", - "ButtonUnlinkOpedId": "Dissocier OpenID", "ButtonUpload": "Téléverser", "ButtonUploadBackup": "Téléverser une sauvegarde", "ButtonUploadCover": "Téléverser une couverture", diff --git a/client/strings/hr.json b/client/strings/hr.json index 94a10a261f..72058d7082 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -98,7 +98,6 @@ "ButtonStats": "Statistika", "ButtonSubmit": "Podnesi", "ButtonTest": "Test", - "ButtonUnlinkOpedId": "Odspoji OpenID", "ButtonUpload": "Učitaj", "ButtonUploadBackup": "Učitaj sigurnosnu kopiju", "ButtonUploadCover": "Učitaj naslovnicu", diff --git a/client/strings/pl.json b/client/strings/pl.json index b156c71dc4..c786a1188f 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -95,7 +95,6 @@ "ButtonStats": "Statystyki", "ButtonSubmit": "Zaloguj", "ButtonTest": "Test", - "ButtonUnlinkOpedId": "Odłącz OpenID", "ButtonUpload": "Wgraj", "ButtonUploadBackup": "Wgraj kopię zapasową", "ButtonUploadCover": "Wgraj okładkę", diff --git a/client/strings/ru.json b/client/strings/ru.json index 221ca1f05d..374ad87976 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -98,7 +98,6 @@ "ButtonStats": "Статистика", "ButtonSubmit": "Применить", "ButtonTest": "Тест", - "ButtonUnlinkOpedId": "Отвязать OpenID", "ButtonUpload": "Загрузить", "ButtonUploadBackup": "Загрузить бэкап", "ButtonUploadCover": "Загрузить обложку", diff --git a/client/strings/sl.json b/client/strings/sl.json index f8954e3a36..8c65f16f9a 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -98,7 +98,6 @@ "ButtonStats": "Statistika", "ButtonSubmit": "Posreduj", "ButtonTest": "Test", - "ButtonUnlinkOpedId": "Prekini povezavo OpenID", "ButtonUpload": "Naloži", "ButtonUploadBackup": "Naloži varnostno kopijo", "ButtonUploadCover": "Naloži naslovnico", From 24923c0009446e2db26ec366b25dd38deb2e2573 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Sep 2024 17:09:34 -0500 Subject: [PATCH 064/539] Version bump v2.13.3 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 852e7a039d..83586a4e0a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.13.2", + "version": "2.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.13.2", + "version": "2.13.3", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index fcd9621135..e33e4ac890 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.13.2", + "version": "2.13.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 984ed9603f..eada191873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.13.2", + "version": "2.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.13.2", + "version": "2.13.3", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index b5b913342b..9ad9cc9435 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.13.2", + "version": "2.13.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 0344a63b480ac9385c5ee019c25eea86c2ed6802 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 3 Sep 2024 17:04:58 -0500 Subject: [PATCH 065/539] Clean out old unused objects --- client/components/app/Appbar.vue | 1 - client/components/app/BookShelfToolbar.vue | 4 - .../components/app/MediaPlayerContainer.vue | 13 +- client/components/app/SideRail.vue | 14 - client/components/cards/LazyBookCard.vue | 9 +- .../components/content/LibraryItemDetails.vue | 59 +--- .../controls/LibraryFilterSelect.vue | 29 -- .../components/controls/LibrarySortSelect.vue | 33 -- client/components/player/PlayerUi.vue | 16 - client/pages/item/_id/index.vue | 38 +-- client/players/LocalVideoPlayer.js | 260 --------------- client/players/PlayerHandler.js | 47 +-- client/players/VideoTrack.js | 32 -- server/controllers/LibraryItemController.js | 2 +- server/controllers/SearchController.js | 1 - server/finders/MusicFinder.js | 12 - server/managers/PlaybackSessionManager.js | 48 ++- server/objects/LibraryItem.js | 111 ++++--- server/objects/PlaybackSession.js | 5 - server/objects/files/LibraryFile.js | 5 +- server/objects/files/VideoFile.js | 109 ------- server/objects/files/VideoTrack.js | 45 --- server/objects/mediaTypes/Music.js | 145 --------- server/objects/mediaTypes/Video.js | 137 -------- server/objects/metadata/MusicMetadata.js | 307 ------------------ server/objects/metadata/VideoMetadata.js | 80 ----- server/utils/constants.js | 4 - server/utils/globals.js | 1 - server/utils/scandir.js | 159 ++++----- 29 files changed, 183 insertions(+), 1543 deletions(-) delete mode 100644 client/players/LocalVideoPlayer.js delete mode 100644 client/players/VideoTrack.js delete mode 100644 server/finders/MusicFinder.js delete mode 100644 server/objects/files/VideoFile.js delete mode 100644 server/objects/files/VideoTrack.js delete mode 100644 server/objects/mediaTypes/Music.js delete mode 100644 server/objects/mediaTypes/Video.js delete mode 100644 server/objects/metadata/MusicMetadata.js delete mode 100644 server/objects/metadata/VideoMetadata.js diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 2d557e32ba..19b8fe3c62 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -264,7 +264,6 @@ export default { libraryItems.forEach((item) => { let subtitle = '' if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ') - else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ') queueItems.push({ libraryItemId: item.id, libraryId: item.libraryId, diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index ff337428f7..00b7ee3439 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -246,9 +246,6 @@ export default { isPodcastLibrary() { return this.currentLibraryMediaType === 'podcast' }, - isMusicLibrary() { - return this.currentLibraryMediaType === 'music' - }, isLibraryPage() { return this.page === '' }, @@ -281,7 +278,6 @@ export default { }, entityName() { if (this.isAlbumsPage) return 'Albums' - if (this.isMusicLibrary) return 'Tracks' if (this.isPodcastLibrary) return this.$strings.LabelPodcasts if (!this.page) return this.$strings.LabelBooks diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue index 61508d7e39..259e0c98bd 100644 --- a/client/components/app/MediaPlayerContainer.vue +++ b/client/components/app/MediaPlayerContainer.vue @@ -1,10 +1,9 @@ - +

{{ $getString('LabelByAuthor', [podcastAuthor]) }}

+

+ by {{ author.name }} +

+

by Unknown

@@ -109,7 +104,7 @@ - + @@ -220,12 +215,6 @@ export default { isPodcast() { return this.libraryItem.mediaType === 'podcast' }, - isVideo() { - return this.libraryItem.mediaType === 'video' - }, - isMusic() { - return this.libraryItem.mediaType === 'music' - }, isMissing() { return this.libraryItem.isMissing }, @@ -240,8 +229,6 @@ export default { }, showPlayButton() { if (this.isMissing || this.isInvalid) return false - if (this.isMusic) return !!this.audioFile - if (this.isVideo) return !!this.videoFile if (this.isPodcast) return this.podcastEpisodes.length return this.tracks.length }, @@ -292,9 +279,6 @@ export default { authors() { return this.mediaMetadata.authors || [] }, - musicArtists() { - return this.mediaMetadata.artists || [] - }, series() { return this.mediaMetadata.series || [] }, @@ -309,7 +293,7 @@ export default { }) }, duration() { - if (!this.tracks.length && !this.audioFile) return 0 + if (!this.tracks.length) return 0 return this.media.duration }, libraryFiles() { @@ -321,18 +305,10 @@ export default { ebookFile() { return this.media.ebookFile }, - videoFile() { - return this.media.videoFile - }, - audioFile() { - // Music track - return this.media.audioFile - }, description() { return this.mediaMetadata.description || '' }, userMediaProgress() { - if (this.isMusic) return null return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) }, userIsFinished() { diff --git a/client/players/LocalVideoPlayer.js b/client/players/LocalVideoPlayer.js deleted file mode 100644 index 128f070b01..0000000000 --- a/client/players/LocalVideoPlayer.js +++ /dev/null @@ -1,260 +0,0 @@ -import Hls from 'hls.js' -import EventEmitter from 'events' - -export default class LocalVideoPlayer extends EventEmitter { - constructor(ctx) { - super() - - this.ctx = ctx - this.player = null - - this.libraryItem = null - this.videoTrack = null - this.isHlsTranscode = null - this.hlsInstance = null - this.usingNativeplayer = false - this.startTime = 0 - this.playWhenReady = false - this.defaultPlaybackRate = 1 - - this.playableMimeTypes = [] - - this.initialize() - } - - initialize() { - if (document.getElementById('video-player')) { - document.getElementById('video-player').remove() - } - var videoEl = document.createElement('video') - videoEl.id = 'video-player' - // videoEl.style.display = 'none' - videoEl.className = 'absolute bg-black z-50' - videoEl.style.height = '216px' - videoEl.style.width = '384px' - videoEl.style.bottom = '80px' - videoEl.style.left = '16px' - document.body.appendChild(videoEl) - this.player = videoEl - - this.player.addEventListener('play', this.evtPlay.bind(this)) - this.player.addEventListener('pause', this.evtPause.bind(this)) - this.player.addEventListener('progress', this.evtProgress.bind(this)) - this.player.addEventListener('ended', this.evtEnded.bind(this)) - this.player.addEventListener('error', this.evtError.bind(this)) - this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this)) - this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this)) - - var mimeTypes = ['video/mp4'] - var mimeTypeCanPlayMap = {} - mimeTypes.forEach((mt) => { - var canPlay = this.player.canPlayType(mt) - mimeTypeCanPlayMap[mt] = canPlay - if (canPlay) this.playableMimeTypes.push(mt) - }) - console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes) - } - - evtPlay() { - this.emit('stateChange', 'PLAYING') - } - evtPause() { - this.emit('stateChange', 'PAUSED') - } - evtProgress() { - var lastBufferTime = this.getLastBufferedTime() - this.emit('buffertimeUpdate', lastBufferTime) - } - evtEnded() { - console.log(`[LocalVideoPlayer] Ended`) - this.emit('finished') - } - evtError(error) { - console.error('Player error', error) - this.emit('error', error) - } - evtLoadedMetadata(data) { - if (!this.isHlsTranscode) { - this.player.currentTime = this.startTime - } - - this.emit('stateChange', 'LOADED') - if (this.playWhenReady) { - this.playWhenReady = false - this.play() - } - } - evtTimeupdate() { - if (this.player.paused) { - this.emit('timeupdate', this.getCurrentTime()) - } - } - - destroy() { - this.destroyHlsInstance() - if (this.player) { - this.player.remove() - } - } - - set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) { - this.libraryItem = libraryItem - this.videoTrack = videoTrack - this.isHlsTranscode = isHlsTranscode - this.playWhenReady = playWhenReady - this.startTime = startTime - - if (this.hlsInstance) { - this.destroyHlsInstance() - } - - if (this.isHlsTranscode) { - this.setHlsStream() - } else { - this.setDirectPlay() - } - } - - setHlsStream() { - // iOS does not support Media Elements but allows for HLS in the native video player - if (!Hls.isSupported()) { - console.warn('HLS is not supported - fallback to using video element') - this.usingNativeplayer = true - this.player.src = this.videoTrack.relativeContentUrl - this.player.currentTime = this.startTime - return - } - - var hlsOptions = { - startPosition: this.startTime || -1 - // No longer needed because token is put in a query string - // xhrSetup: (xhr) => { - // xhr.setRequestHeader('Authorization', `Bearer ${this.token}`) - // } - } - this.hlsInstance = new Hls(hlsOptions) - - this.hlsInstance.attachMedia(this.player) - this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => { - this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl) - - this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { - console.log('[HLS] Manifest Parsed') - }) - - this.hlsInstance.on(Hls.Events.ERROR, (e, data) => { - console.error('[HLS] Error', data.type, data.details, data) - if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) { - console.error('[HLS] BUFFER STALLED ERROR') - } - }) - this.hlsInstance.on(Hls.Events.DESTROYING, () => { - console.log('[HLS] Destroying HLS Instance') - }) - }) - } - - setDirectPlay() { - this.player.src = this.videoTrack.relativeContentUrl - console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`) - this.player.load() - } - - destroyHlsInstance() { - if (!this.hlsInstance) return - if (this.hlsInstance.destroy) { - var temp = this.hlsInstance - temp.destroy() - } - this.hlsInstance = null - } - - async resetStream(startTime) { - this.destroyHlsInstance() - await new Promise((resolve) => setTimeout(resolve, 1000)) - this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true) - } - - playPause() { - if (!this.player) return - if (this.player.paused) this.play() - else this.pause() - } - - play() { - if (this.player) this.player.play() - } - - pause() { - if (this.player) this.player.pause() - } - - getCurrentTime() { - return this.player ? this.player.currentTime : 0 - } - - getDuration() { - return this.videoTrack.duration - } - - setPlaybackRate(playbackRate) { - if (!this.player) return - this.defaultPlaybackRate = playbackRate - this.player.playbackRate = playbackRate - } - - seek(time) { - if (!this.player) return - this.player.currentTime = Math.max(0, time) - } - - setVolume(volume) { - if (!this.player) return - this.player.volume = volume - } - - // Utils - isValidDuration(duration) { - if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) { - return true - } - return false - } - - getBufferedRanges() { - if (!this.player) return [] - const ranges = [] - const seekable = this.player.buffered || [] - - let offset = 0 - - for (let i = 0, length = seekable.length; i < length; i++) { - let start = seekable.start(i) - let end = seekable.end(i) - if (!this.isValidDuration(start)) { - start = 0 - } - if (!this.isValidDuration(end)) { - end = 0 - continue - } - - ranges.push({ - start: start + offset, - end: end + offset - }) - } - return ranges - } - - getLastBufferedTime() { - var bufferedRanges = this.getBufferedRanges() - if (!bufferedRanges.length) return 0 - - var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime) - if (buff) return buff.end - - var last = bufferedRanges[bufferedRanges.length - 1] - return last.end - } -} \ No newline at end of file diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index 42d76bd033..a327f831f2 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -1,8 +1,6 @@ import LocalAudioPlayer from './LocalAudioPlayer' -import LocalVideoPlayer from './LocalVideoPlayer' import CastPlayer from './CastPlayer' import AudioTrack from './AudioTrack' -import VideoTrack from './VideoTrack' export default class PlayerHandler { constructor(ctx) { @@ -16,8 +14,6 @@ export default class PlayerHandler { this.player = null this.playerState = 'IDLE' this.isHlsTranscode = false - this.isVideo = false - this.isMusic = false this.currentSessionId = null this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page) this.startTime = 0 @@ -65,12 +61,10 @@ export default class PlayerHandler { load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) { this.libraryItem = libraryItem - this.isVideo = libraryItem.mediaType === 'video' - this.isMusic = libraryItem.mediaType === 'music' this.episodeId = episodeId this.playWhenReady = playWhenReady - this.initialPlaybackRate = this.isMusic ? 1 : playbackRate + this.initialPlaybackRate = playbackRate this.startTimeOverride = startTimeOverride == null || isNaN(startTimeOverride) ? undefined : Number(startTimeOverride) @@ -97,7 +91,7 @@ export default class PlayerHandler { this.playWhenReady = playWhenReady this.prepare() } - } else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) { + } else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer)) { console.log('[PlayerHandler] Switching to local player') this.stopPlayInterval() @@ -107,11 +101,7 @@ export default class PlayerHandler { this.player.destroy() } - if (this.isVideo) { - this.player = new LocalVideoPlayer(this.ctx) - } else { - this.player = new LocalAudioPlayer(this.ctx) - } + this.player = new LocalAudioPlayer(this.ctx) this.setPlayerListeners() @@ -203,7 +193,7 @@ export default class PlayerHandler { supportedMimeTypes: this.player.playableMimeTypes, mediaPlayer: this.isCasting ? 'chromecast' : 'html5', forceTranscode, - forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast + forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast } const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play` @@ -218,7 +208,6 @@ export default class PlayerHandler { if (!this.player) this.switchPlayer() // Must set player first for open sessions this.libraryItem = session.libraryItem - this.isVideo = session.libraryItem.mediaType === 'video' this.playWhenReady = false this.initialPlaybackRate = playbackRate this.startTimeOverride = undefined @@ -237,28 +226,16 @@ export default class PlayerHandler { console.log('[PlayerHandler] Preparing Session', session) - if (session.videoTrack) { - var videoTrack = new VideoTrack(session.videoTrack, this.userToken) - - this.ctx.playerLoading = true - this.isHlsTranscode = true - if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) { - this.isHlsTranscode = false - } - - this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady) - } else { - var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken)) - - this.ctx.playerLoading = true - this.isHlsTranscode = true - if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) { - this.isHlsTranscode = false - } + var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken)) - this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady) + this.ctx.playerLoading = true + this.isHlsTranscode = true + if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) { + this.isHlsTranscode = false } + this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady) + // browser media session api this.ctx.setMediaSession() } @@ -333,8 +310,6 @@ export default class PlayerHandler { } sendProgressSync(currentTime) { - if (this.isMusic) return - const diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime) if (diffSinceLastSync < 1) return diff --git a/client/players/VideoTrack.js b/client/players/VideoTrack.js deleted file mode 100644 index 92bec5eb53..0000000000 --- a/client/players/VideoTrack.js +++ /dev/null @@ -1,32 +0,0 @@ -export default class VideoTrack { - constructor(track, userToken) { - this.index = track.index || 0 - this.startOffset = track.startOffset || 0 // Total time of all previous tracks - this.duration = track.duration || 0 - this.title = track.title || '' - this.contentUrl = track.contentUrl || null - this.mimeType = track.mimeType - this.metadata = track.metadata || {} - - this.userToken = userToken - } - - get fullContentUrl() { - if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl - - if (process.env.NODE_ENV === 'development') { - return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}` - } - return `${window.location.origin}${this.contentUrl}?token=${this.userToken}` - } - - get relativeContentUrl() { - if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl - - if (process.env.NODE_ENV === 'development') { - return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}` - } - - return this.contentUrl + `?token=${this.userToken}` - } -} \ No newline at end of file diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 472f567856..c77e1d3a59 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -384,7 +384,7 @@ class LibraryItemController { * @param {Response} res */ startPlaybackSession(req, res) { - if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') { + if (!req.libraryItem.media.numTracks) { Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`) return res.sendStatus(404) } diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index cfe4e6d3ea..a19ff87667 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -3,7 +3,6 @@ const Logger = require('../Logger') const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') -const MusicFinder = require('../finders/MusicFinder') const Database = require('../Database') const { isValidASIN } = require('../utils') diff --git a/server/finders/MusicFinder.js b/server/finders/MusicFinder.js deleted file mode 100644 index 3569576f66..0000000000 --- a/server/finders/MusicFinder.js +++ /dev/null @@ -1,12 +0,0 @@ -const MusicBrainz = require('../providers/MusicBrainz') - -class MusicFinder { - constructor() { - this.musicBrainz = new MusicBrainz() - } - - searchTrack(options) { - return this.musicBrainz.searchTrack(options) - } -} -module.exports = new MusicFinder() \ No newline at end of file diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index cafd6ff451..ccdf6c7a52 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -293,37 +293,27 @@ class PlaybackSessionManager { const newPlaybackSession = new PlaybackSession() newPlaybackSession.setData(libraryItem, user.id, mediaPlayer, deviceInfo, userStartTime, episodeId) - if (libraryItem.mediaType === 'video') { - if (shouldDirectPlay) { - Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id}`) - newPlaybackSession.videoTrack = libraryItem.media.getVideoTrack() - newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY - } else { - // HLS not supported for video yet - } + let audioTracks = [] + if (shouldDirectPlay) { + Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`) + audioTracks = libraryItem.getDirectPlayTracklist(episodeId) + newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY } else { - let audioTracks = [] - if (shouldDirectPlay) { - Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`) - audioTracks = libraryItem.getDirectPlayTracklist(episodeId) - newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY - } else { - Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`) - const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime) - await stream.generatePlaylist() - stream.start() // Start transcode - - audioTracks = [stream.getAudioTrack()] - newPlaybackSession.stream = stream - newPlaybackSession.playMethod = PlayMethod.TRANSCODE - - stream.on('closed', () => { - Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}" (Device: ${newPlaybackSession.deviceDescription})`) - newPlaybackSession.stream = null - }) - } - newPlaybackSession.audioTracks = audioTracks + Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`) + const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime) + await stream.generatePlaylist() + stream.start() // Start transcode + + audioTracks = [stream.getAudioTrack()] + newPlaybackSession.stream = stream + newPlaybackSession.playMethod = PlayMethod.TRANSCODE + + stream.on('closed', () => { + Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}" (Device: ${newPlaybackSession.deviceDescription})`) + newPlaybackSession.stream = null + }) } + newPlaybackSession.audioTracks = audioTracks this.sessions.push(newPlaybackSession) SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions)) diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 3b92bdccf5..0259ee4c92 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,12 +1,10 @@ -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') const LibraryFile = require('./files/LibraryFile') const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') -const Video = require('./mediaTypes/Video') -const Music = require('./mediaTypes/Music') const { areEquivalent, copyValue } = require('../utils/index') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') @@ -74,14 +72,10 @@ class LibraryItem { this.media = new Book(libraryItem.media) } else if (this.mediaType === 'podcast') { this.media = new Podcast(libraryItem.media) - } else if (this.mediaType === 'video') { - this.media = new Video(libraryItem.media) - } else if (this.mediaType === 'music') { - this.media = new Music(libraryItem.media) } this.media.libraryItemId = this.id - this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f)) + this.libraryFiles = libraryItem.libraryFiles.map((f) => new LibraryFile(f)) // Migration for v2.2.23 to set ebook library files as supplementary if (this.isBook && this.media.ebookFile) { @@ -91,7 +85,6 @@ class LibraryItem { } } } - } toJSON() { @@ -115,7 +108,7 @@ class LibraryItem { isInvalid: !!this.isInvalid, mediaType: this.mediaType, media: this.media.toJSON(), - libraryFiles: this.libraryFiles.map(f => f.toJSON()) + libraryFiles: this.libraryFiles.map((f) => f.toJSON()) } } @@ -165,21 +158,24 @@ class LibraryItem { isInvalid: !!this.isInvalid, mediaType: this.mediaType, media: this.media.toJSONExpanded(), - libraryFiles: this.libraryFiles.map(f => f.toJSON()), + libraryFiles: this.libraryFiles.map((f) => f.toJSON()), size: this.size } } - get isPodcast() { return this.mediaType === 'podcast' } - get isBook() { return this.mediaType === 'book' } - get isMusic() { return this.mediaType === 'music' } + get isPodcast() { + return this.mediaType === 'podcast' + } + get isBook() { + return this.mediaType === 'book' + } get size() { let total = 0 - this.libraryFiles.forEach((lf) => total += lf.metadata.size) + this.libraryFiles.forEach((lf) => (total += lf.metadata.size)) return total } get hasAudioFiles() { - return this.libraryFiles.some(lf => lf.fileType === 'audio') + return this.libraryFiles.some((lf) => lf.fileType === 'audio') } get hasMediaEntities() { return this.media.hasMediaEntities @@ -201,17 +197,16 @@ class LibraryItem { for (const key in payload) { if (key === 'libraryFiles') { - this.libraryFiles = payload.libraryFiles.map(lf => lf.clone()) + this.libraryFiles = payload.libraryFiles.map((lf) => lf.clone()) // Set cover image - const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image') - const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path)) + const imageFiles = this.libraryFiles.filter((lf) => lf.fileType === 'image') + const coverMatch = imageFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path)) if (coverMatch) { this.media.coverPath = coverMatch.metadata.path } else if (imageFiles.length) { this.media.coverPath = imageFiles[0].metadata.path } - } else if (this[key] !== undefined && key !== 'media') { this[key] = payload[key] } @@ -283,46 +278,50 @@ class LibraryItem { const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - metadataLibraryFile = new LibraryFile() - await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - this.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino + return fs + .writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)) + .then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + metadataLibraryFile = new LibraryFile() + await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + this.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) + if (libraryItemDirTimestamps) { + this.mtimeMs = libraryItemDirTimestamps.mtimeMs + this.ctimeMs = libraryItemDirTimestamps.ctimeMs } } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtimeMs = libraryItemDirTimestamps.mtimeMs - this.ctimeMs = libraryItemDirTimestamps.ctimeMs - } - } - - Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }).catch((error) => { - Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) - return null - }).finally(() => { - this.isSavingMetadata = false - }) + Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) + + return metadataLibraryFile + }) + .catch((error) => { + Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) + return null + }) + .finally(() => { + this.isSavingMetadata = false + }) } removeLibraryFile(ino) { if (!ino) return false - const libraryFile = this.libraryFiles.find(lf => lf.ino === ino) + const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino) if (libraryFile) { - this.libraryFiles = this.libraryFiles.filter(lf => lf.ino !== ino) + this.libraryFiles = this.libraryFiles.filter((lf) => lf.ino !== ino) this.updatedAt = Date.now() return true } @@ -333,15 +332,15 @@ class LibraryItem { * Set the EBookFile from a LibraryFile * If null then ebookFile will be removed from the book * all ebook library files that are not primary are marked as supplementary - * - * @param {LibraryFile} [libraryFile] + * + * @param {LibraryFile} [libraryFile] */ setPrimaryEbook(ebookLibraryFile = null) { - const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile) + const ebookLibraryFiles = this.libraryFiles.filter((lf) => lf.isEBookFile) for (const libraryFile of ebookLibraryFiles) { libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino } this.media.setEbookFile(ebookLibraryFile) } } -module.exports = LibraryItem \ No newline at end of file +module.exports = LibraryItem diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index 4a5b7d1ef4..cd74089ac4 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -4,7 +4,6 @@ const serverVersion = require('../../package.json').version const BookMetadata = require('./metadata/BookMetadata') const PodcastMetadata = require('./metadata/PodcastMetadata') const DeviceInfo = require('./DeviceInfo') -const VideoMetadata = require('./metadata/VideoMetadata') class PlaybackSession { constructor(session) { @@ -41,7 +40,6 @@ class PlaybackSession { // Not saved in DB this.lastSave = 0 this.audioTracks = [] - this.videoTrack = null this.stream = null // Used for share sessions this.shareSessionId = null @@ -114,7 +112,6 @@ class PlaybackSession { startedAt: this.startedAt, updatedAt: this.updatedAt, audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }), - videoTrack: this.videoTrack?.toJSON() || null, libraryItem: libraryItem?.toJSONExpanded() || null } } @@ -157,8 +154,6 @@ class PlaybackSession { this.mediaMetadata = new BookMetadata(session.mediaMetadata) } else if (this.mediaType === 'podcast') { this.mediaMetadata = new PodcastMetadata(session.mediaMetadata) - } else if (this.mediaType === 'video') { - this.mediaMetadata = new VideoMetadata(session.mediaMetadata) } } this.displayTitle = session.displayTitle || '' diff --git a/server/objects/files/LibraryFile.js b/server/objects/files/LibraryFile.js index 395e11cc1b..8669e38767 100644 --- a/server/objects/files/LibraryFile.js +++ b/server/objects/files/LibraryFile.js @@ -43,14 +43,13 @@ class LibraryFile { if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image' if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio' if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook' - if (globals.SupportedVideoTypes.includes(this.metadata.format)) return 'video' if (globals.TextFileTypes.includes(this.metadata.format)) return 'text' if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata' return 'unknown' } get isMediaFile() { - return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video' + return this.fileType === 'audio' || this.fileType === 'ebook' } get isEBookFile() { @@ -75,4 +74,4 @@ class LibraryFile { this.updatedAt = Date.now() } } -module.exports = LibraryFile \ No newline at end of file +module.exports = LibraryFile diff --git a/server/objects/files/VideoFile.js b/server/objects/files/VideoFile.js deleted file mode 100644 index 51fc382b6c..0000000000 --- a/server/objects/files/VideoFile.js +++ /dev/null @@ -1,109 +0,0 @@ -const { VideoMimeType } = require('../../utils/constants') -const FileMetadata = require('../metadata/FileMetadata') - -class VideoFile { - constructor(data) { - this.index = null - this.ino = null - this.metadata = null - this.addedAt = null - this.updatedAt = null - - this.format = null - this.duration = null - this.bitRate = null - this.language = null - this.codec = null - this.timeBase = null - this.frameRate = null - this.width = null - this.height = null - this.embeddedCoverArt = null - - this.invalid = false - this.error = null - - if (data) { - this.construct(data) - } - } - - toJSON() { - return { - index: this.index, - ino: this.ino, - metadata: this.metadata.toJSON(), - addedAt: this.addedAt, - updatedAt: this.updatedAt, - invalid: !!this.invalid, - error: this.error || null, - format: this.format, - duration: this.duration, - bitRate: this.bitRate, - language: this.language, - codec: this.codec, - timeBase: this.timeBase, - frameRate: this.frameRate, - width: this.width, - height: this.height, - embeddedCoverArt: this.embeddedCoverArt, - mimeType: this.mimeType - } - } - - construct(data) { - this.index = data.index - this.ino = data.ino - this.metadata = new FileMetadata(data.metadata || {}) - this.addedAt = data.addedAt - this.updatedAt = data.updatedAt - this.invalid = !!data.invalid - this.error = data.error || null - - this.format = data.format - this.duration = data.duration - this.bitRate = data.bitRate - this.language = data.language - this.codec = data.codec || null - this.timeBase = data.timeBase - this.frameRate = data.frameRate - this.width = data.width - this.height = data.height - this.embeddedCoverArt = data.embeddedCoverArt || null - } - - get mimeType() { - var format = this.metadata.format.toUpperCase() - if (VideoMimeType[format]) { - return VideoMimeType[format] - } else { - return VideoMimeType.MP4 - } - } - - clone() { - return new VideoFile(this.toJSON()) - } - - setDataFromProbe(libraryFile, probeData) { - this.ino = libraryFile.ino || null - - this.metadata = libraryFile.metadata.clone() - this.addedAt = Date.now() - this.updatedAt = Date.now() - - const videoStream = probeData.videoStream - - this.format = probeData.format - this.duration = probeData.duration - this.bitRate = videoStream.bit_rate || probeData.bitRate || null - this.language = probeData.language - this.codec = videoStream.codec || null - this.timeBase = videoStream.time_base - this.frameRate = videoStream.frame_rate || null - this.width = videoStream.width || null - this.height = videoStream.height || null - this.embeddedCoverArt = probeData.embeddedCoverArt - } -} -module.exports = VideoFile \ No newline at end of file diff --git a/server/objects/files/VideoTrack.js b/server/objects/files/VideoTrack.js deleted file mode 100644 index b1f1e3541e..0000000000 --- a/server/objects/files/VideoTrack.js +++ /dev/null @@ -1,45 +0,0 @@ -const Path = require('path') -const { encodeUriPath } = require('../../utils/fileUtils') - -class VideoTrack { - constructor() { - this.index = null - this.duration = null - this.title = null - this.contentUrl = null - this.mimeType = null - this.codec = null - this.metadata = null - } - - toJSON() { - return { - index: this.index, - duration: this.duration, - title: this.title, - contentUrl: this.contentUrl, - mimeType: this.mimeType, - codec: this.codec, - metadata: this.metadata ? this.metadata.toJSON() : null - } - } - - setData(itemId, videoFile) { - this.index = videoFile.index - this.duration = videoFile.duration - this.title = videoFile.metadata.filename || '' - this.contentUrl = Path.join(`${global.RouterBasePath}/api/items/${itemId}/file/${videoFile.ino}`, encodeUriPath(videoFile.metadata.relPath)) - this.mimeType = videoFile.mimeType - this.codec = videoFile.codec - this.metadata = videoFile.metadata.clone() - } - - setFromStream(title, duration, contentUrl) { - this.index = 1 - this.duration = duration - this.title = title - this.contentUrl = contentUrl - this.mimeType = 'application/vnd.apple.mpegurl' - } -} -module.exports = VideoTrack \ No newline at end of file diff --git a/server/objects/mediaTypes/Music.js b/server/objects/mediaTypes/Music.js deleted file mode 100644 index d4b8a518a3..0000000000 --- a/server/objects/mediaTypes/Music.js +++ /dev/null @@ -1,145 +0,0 @@ -const Logger = require('../../Logger') -const AudioFile = require('../files/AudioFile') -const AudioTrack = require('../files/AudioTrack') -const MusicMetadata = require('../metadata/MusicMetadata') -const { areEquivalent, copyValue } = require('../../utils/index') -const { filePathToPOSIX } = require('../../utils/fileUtils') - -class Music { - constructor(music) { - this.libraryItemId = null - this.metadata = null - this.coverPath = null - this.tags = [] - this.audioFile = null - - if (music) { - this.construct(music) - } - } - - construct(music) { - this.libraryItemId = music.libraryItemId - this.metadata = new MusicMetadata(music.metadata) - this.coverPath = music.coverPath - this.tags = [...music.tags] - this.audioFile = new AudioFile(music.audioFile) - } - - toJSON() { - return { - libraryItemId: this.libraryItemId, - metadata: this.metadata.toJSON(), - coverPath: this.coverPath, - tags: [...this.tags], - audioFile: this.audioFile.toJSON(), - } - } - - toJSONMinified() { - return { - metadata: this.metadata.toJSONMinified(), - coverPath: this.coverPath, - tags: [...this.tags], - audioFile: this.audioFile.toJSON(), - duration: this.duration, - size: this.size - } - } - - toJSONExpanded() { - return { - libraryItemId: this.libraryItemId, - metadata: this.metadata.toJSONExpanded(), - coverPath: this.coverPath, - tags: [...this.tags], - audioFile: this.audioFile.toJSON(), - duration: this.duration, - size: this.size - } - } - - get size() { - return this.audioFile.metadata.size - } - get hasMediaEntities() { - return !!this.audioFile - } - get duration() { - return this.audioFile.duration || 0 - } - get audioTrack() { - const audioTrack = new AudioTrack() - audioTrack.setData(this.libraryItemId, this.audioFile, 0) - return audioTrack - } - get numTracks() { - return 1 - } - - update(payload) { - const json = this.toJSON() - delete json.episodes // do not update media entities here - let hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined) { - if (key === 'metadata') { - if (this.metadata.update(payload.metadata)) { - hasUpdates = true - } - } else if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - Logger.debug('[Podcast] Key updated', key, this[key]) - hasUpdates = true - } - } - } - return hasUpdates - } - - updateCover(coverPath) { - coverPath = filePathToPOSIX(coverPath) - if (this.coverPath === coverPath) return false - this.coverPath = coverPath - return true - } - - removeFileWithInode(inode) { - return false - } - - findFileWithInode(inode) { - return (this.audioFile && this.audioFile.ino === inode) ? this.audioFile : null - } - - setData(mediaData) { - this.metadata = new MusicMetadata() - if (mediaData.metadata) { - this.metadata.setData(mediaData.metadata) - } - - this.coverPath = mediaData.coverPath || null - } - - setAudioFile(audioFile) { - this.audioFile = audioFile - } - - // Only checks container format - checkCanDirectPlay(payload) { - return true - } - - getDirectPlayTracklist() { - return [this.audioTrack] - } - - getPlaybackTitle() { - return this.metadata.title - } - - getPlaybackAuthor() { - return this.metadata.artist - } -} -module.exports = Music \ No newline at end of file diff --git a/server/objects/mediaTypes/Video.js b/server/objects/mediaTypes/Video.js deleted file mode 100644 index 940eab0bbc..0000000000 --- a/server/objects/mediaTypes/Video.js +++ /dev/null @@ -1,137 +0,0 @@ -const Logger = require('../../Logger') -const VideoFile = require('../files/VideoFile') -const VideoTrack = require('../files/VideoTrack') -const VideoMetadata = require('../metadata/VideoMetadata') -const { areEquivalent, copyValue } = require('../../utils/index') -const { filePathToPOSIX } = require('../../utils/fileUtils') - -class Video { - constructor(video) { - this.libraryItemId = null - this.metadata = null - this.coverPath = null - this.tags = [] - this.episodes = [] - - this.autoDownloadEpisodes = false - this.lastEpisodeCheck = 0 - - this.lastCoverSearch = null - this.lastCoverSearchQuery = null - - if (video) { - this.construct(video) - } - } - - construct(video) { - this.libraryItemId = video.libraryItemId - this.metadata = new VideoMetadata(video.metadata) - this.coverPath = video.coverPath - this.tags = [...video.tags] - this.videoFile = new VideoFile(video.videoFile) - } - - toJSON() { - return { - libraryItemId: this.libraryItemId, - metadata: this.metadata.toJSONExpanded(), - coverPath: this.coverPath, - tags: [...this.tags], - videoFile: this.videoFile.toJSON() - } - } - - toJSONMinified() { - return { - metadata: this.metadata.toJSONMinified(), - coverPath: this.coverPath, - tags: [...this.tags], - videoFile: this.videoFile.toJSON(), - size: this.size - } - } - - toJSONExpanded() { - return { - libraryItemId: this.libraryItemId, - metadata: this.metadata.toJSONExpanded(), - coverPath: this.coverPath, - tags: [...this.tags], - videoFile: this.videoFile.toJSON(), - size: this.size - } - } - - get size() { - return this.videoFile.metadata.size - } - get hasMediaEntities() { - return true - } - get duration() { - return 0 - } - - update(payload) { - var json = this.toJSON() - var hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined) { - if (key === 'metadata') { - if (this.metadata.update(payload.metadata)) { - hasUpdates = true - } - } else if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - Logger.debug('[Video] Key updated', key, this[key]) - hasUpdates = true - } - } - } - return hasUpdates - } - - updateCover(coverPath) { - coverPath = filePathToPOSIX(coverPath) - if (this.coverPath === coverPath) return false - this.coverPath = coverPath - return true - } - - removeFileWithInode(inode) { - - } - - findFileWithInode(inode) { - return null - } - - setVideoFile(videoFile) { - this.videoFile = videoFile - } - - setData(mediaMetadata) { - this.metadata = new VideoMetadata() - if (mediaMetadata.metadata) { - this.metadata.setData(mediaMetadata.metadata) - } - - this.coverPath = mediaMetadata.coverPath || null - } - - getPlaybackTitle() { - return this.metadata.title - } - - getPlaybackAuthor() { - return '' - } - - getVideoTrack() { - var track = new VideoTrack() - track.setData(this.libraryItemId, this.videoFile) - return track - } -} -module.exports = Video \ No newline at end of file diff --git a/server/objects/metadata/MusicMetadata.js b/server/objects/metadata/MusicMetadata.js deleted file mode 100644 index 90a887e07a..0000000000 --- a/server/objects/metadata/MusicMetadata.js +++ /dev/null @@ -1,307 +0,0 @@ -const Logger = require('../../Logger') -const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') - -class MusicMetadata { - constructor(metadata) { - this.title = null - this.artists = [] // Array of strings - this.album = null - this.albumArtist = null - this.genres = [] // Array of strings - this.composer = null - this.originalYear = null - this.releaseDate = null - this.releaseCountry = null - this.releaseType = null - this.releaseStatus = null - this.recordLabel = null - this.language = null - this.explicit = false - - this.discNumber = null - this.discTotal = null - this.trackNumber = null - this.trackTotal = null - - this.isrc = null - this.musicBrainzTrackId = null - this.musicBrainzAlbumId = null - this.musicBrainzAlbumArtistId = null - this.musicBrainzArtistId = null - - if (metadata) { - this.construct(metadata) - } - } - - construct(metadata) { - this.title = metadata.title - this.artists = metadata.artists ? [...metadata.artists] : [] - this.album = metadata.album - this.albumArtist = metadata.albumArtist - this.genres = metadata.genres ? [...metadata.genres] : [] - this.composer = metadata.composer || null - this.originalYear = metadata.originalYear || null - this.releaseDate = metadata.releaseDate || null - this.releaseCountry = metadata.releaseCountry || null - this.releaseType = metadata.releaseType || null - this.releaseStatus = metadata.releaseStatus || null - this.recordLabel = metadata.recordLabel || null - this.language = metadata.language || null - this.explicit = !!metadata.explicit - this.discNumber = metadata.discNumber || null - this.discTotal = metadata.discTotal || null - this.trackNumber = metadata.trackNumber || null - this.trackTotal = metadata.trackTotal || null - this.isrc = metadata.isrc || null - this.musicBrainzTrackId = metadata.musicBrainzTrackId || null - this.musicBrainzAlbumId = metadata.musicBrainzAlbumId || null - this.musicBrainzAlbumArtistId = metadata.musicBrainzAlbumArtistId || null - this.musicBrainzArtistId = metadata.musicBrainzArtistId || null - } - - toJSON() { - return { - title: this.title, - artists: [...this.artists], - album: this.album, - albumArtist: this.albumArtist, - genres: [...this.genres], - composer: this.composer, - originalYear: this.originalYear, - releaseDate: this.releaseDate, - releaseCountry: this.releaseCountry, - releaseType: this.releaseType, - releaseStatus: this.releaseStatus, - recordLabel: this.recordLabel, - language: this.language, - explicit: this.explicit, - discNumber: this.discNumber, - discTotal: this.discTotal, - trackNumber: this.trackNumber, - trackTotal: this.trackTotal, - isrc: this.isrc, - musicBrainzTrackId: this.musicBrainzTrackId, - musicBrainzAlbumId: this.musicBrainzAlbumId, - musicBrainzAlbumArtistId: this.musicBrainzAlbumArtistId, - musicBrainzArtistId: this.musicBrainzArtistId - } - } - - toJSONMinified() { - return { - title: this.title, - titleIgnorePrefix: this.titlePrefixAtEnd, - artists: [...this.artists], - album: this.album, - albumArtist: this.albumArtist, - genres: [...this.genres], - composer: this.composer, - originalYear: this.originalYear, - releaseDate: this.releaseDate, - releaseCountry: this.releaseCountry, - releaseType: this.releaseType, - releaseStatus: this.releaseStatus, - recordLabel: this.recordLabel, - language: this.language, - explicit: this.explicit, - discNumber: this.discNumber, - discTotal: this.discTotal, - trackNumber: this.trackNumber, - trackTotal: this.trackTotal, - isrc: this.isrc, - musicBrainzTrackId: this.musicBrainzTrackId, - musicBrainzAlbumId: this.musicBrainzAlbumId, - musicBrainzAlbumArtistId: this.musicBrainzAlbumArtistId, - musicBrainzArtistId: this.musicBrainzArtistId - } - } - - toJSONExpanded() { - return this.toJSONMinified() - } - - clone() { - return new MusicMetadata(this.toJSON()) - } - - get titleIgnorePrefix() { - return getTitleIgnorePrefix(this.title) - } - - get titlePrefixAtEnd() { - return getTitlePrefixAtEnd(this.title) - } - - setData(mediaMetadata = {}) { - this.title = mediaMetadata.title || null - this.artist = mediaMetadata.artist || null - this.album = mediaMetadata.album || null - } - - update(payload) { - const json = this.toJSON() - let hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined) { - if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - Logger.debug('[MusicMetadata] Key updated', key, this[key]) - hasUpdates = true - } - } - } - return hasUpdates - } - - parseArtistsTag(artistsTag) { - if (!artistsTag || !artistsTag.length) return [] - const separators = ['/', '//', ';'] - for (let i = 0; i < separators.length; i++) { - if (artistsTag.includes(separators[i])) { - return artistsTag.split(separators[i]).map(artist => artist.trim()).filter(a => !!a) - } - } - return [artistsTag] - } - - parseGenresTag(genreTag) { - if (!genreTag || !genreTag.length) return [] - const separators = ['/', '//', ';'] - for (let i = 0; i < separators.length; i++) { - if (genreTag.includes(separators[i])) { - return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g) - } - } - return [genreTag] - } - - setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { - const MetadataMapArray = [ - { - tag: 'tagTitle', - key: 'title', - }, - { - tag: 'tagArtist', - key: 'artists' - }, - { - tag: 'tagAlbumArtist', - key: 'albumArtist' - }, - { - tag: 'tagAlbum', - key: 'album', - }, - { - tag: 'tagPublisher', - key: 'recordLabel' - }, - { - tag: 'tagComposer', - key: 'composer' - }, - { - tag: 'tagDate', - key: 'releaseDate' - }, - { - tag: 'tagReleaseCountry', - key: 'releaseCountry' - }, - { - tag: 'tagReleaseType', - key: 'releaseType' - }, - { - tag: 'tagReleaseStatus', - key: 'releaseStatus' - }, - { - tag: 'tagOriginalYear', - key: 'originalYear' - }, - { - tag: 'tagGenre', - key: 'genres' - }, - { - tag: 'tagLanguage', - key: 'language' - }, - { - tag: 'tagLanguage', - key: 'language' - }, - { - tag: 'tagISRC', - key: 'isrc' - }, - { - tag: 'tagMusicBrainzTrackId', - key: 'musicBrainzTrackId' - }, - { - tag: 'tagMusicBrainzAlbumId', - key: 'musicBrainzAlbumId' - }, - { - tag: 'tagMusicBrainzAlbumArtistId', - key: 'musicBrainzAlbumArtistId' - }, - { - tag: 'tagMusicBrainzArtistId', - key: 'musicBrainzArtistId' - }, - { - tag: 'trackNumber', - key: 'trackNumber' - }, - { - tag: 'trackTotal', - key: 'trackTotal' - }, - { - tag: 'discNumber', - key: 'discNumber' - }, - { - tag: 'discTotal', - key: 'discTotal' - } - ] - - const updatePayload = {} - - // Metadata is only mapped to the music track if it is empty - MetadataMapArray.forEach((mapping) => { - let value = audioFileMetaTags[mapping.tag] - - // let tagToUse = mapping.tag - if (!value && mapping.altTag) { - value = audioFileMetaTags[mapping.altTag] - // tagToUse = mapping.altTag - } - - if (value && (typeof value === 'string' || typeof value === 'number')) { - value = value.toString().trim() // Trim whitespace - - if (mapping.key === 'artists' && (!this.artists.length || overrideExistingDetails)) { - updatePayload.artists = this.parseArtistsTag(value) - } else if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) { - updatePayload.genres = this.parseGenresTag(value) - } else if (!this[mapping.key] || overrideExistingDetails) { - updatePayload[mapping.key] = value - // Logger.debug(`[Book] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`) - } - } - }) - - if (Object.keys(updatePayload).length) { - return this.update(updatePayload) - } - return false - } -} -module.exports = MusicMetadata \ No newline at end of file diff --git a/server/objects/metadata/VideoMetadata.js b/server/objects/metadata/VideoMetadata.js deleted file mode 100644 index a2194d15e4..0000000000 --- a/server/objects/metadata/VideoMetadata.js +++ /dev/null @@ -1,80 +0,0 @@ -const Logger = require('../../Logger') -const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') - -class VideoMetadata { - constructor(metadata) { - this.title = null - this.description = null - this.explicit = false - this.language = null - - if (metadata) { - this.construct(metadata) - } - } - - construct(metadata) { - this.title = metadata.title - this.description = metadata.description - this.explicit = metadata.explicit - this.language = metadata.language || null - } - - toJSON() { - return { - title: this.title, - description: this.description, - explicit: this.explicit, - language: this.language - } - } - - toJSONMinified() { - return { - title: this.title, - titleIgnorePrefix: this.titlePrefixAtEnd, - description: this.description, - explicit: this.explicit, - language: this.language - } - } - - toJSONExpanded() { - return this.toJSONMinified() - } - - clone() { - return new VideoMetadata(this.toJSON()) - } - - get titleIgnorePrefix() { - return getTitleIgnorePrefix(this.title) - } - - get titlePrefixAtEnd() { - return getTitlePrefixAtEnd(this.title) - } - - setData(mediaMetadata = {}) { - this.title = mediaMetadata.title || null - this.description = mediaMetadata.description || null - this.explicit = !!mediaMetadata.explicit - this.language = mediaMetadata.language || null - } - - update(payload) { - var json = this.toJSON() - var hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined) { - if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - Logger.debug('[VideoMetadata] Key updated', key, this[key]) - hasUpdates = true - } - } - } - return hasUpdates - } -} -module.exports = VideoMetadata \ No newline at end of file diff --git a/server/utils/constants.js b/server/utils/constants.js index 7a21d2ddcc..cbfe65f207 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -51,7 +51,3 @@ module.exports.AudioMimeType = { AWB: 'audio/amr-wb', CAF: 'audio/x-caf' } - -module.exports.VideoMimeType = { - MP4: 'video/mp4' -} \ No newline at end of file diff --git a/server/utils/globals.js b/server/utils/globals.js index b24fc76d04..877cf07a08 100644 --- a/server/utils/globals.js +++ b/server/utils/globals.js @@ -2,7 +2,6 @@ const globals = { SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], - SupportedVideoTypes: ['mp4'], TextFileTypes: ['txt', 'nfo'], MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] } diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 21c28b8cf3..ff21e814f2 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -19,8 +19,7 @@ const parseNameString = require('./parsers/parseNameString') function isMediaFile(mediaType, ext, audiobooksOnly = false) { if (!ext) return false const extclean = ext.slice(1).toLowerCase() - if (mediaType === 'podcast' || mediaType === 'music') return globals.SupportedAudioTypes.includes(extclean) - else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean) + if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean) else if (audiobooksOnly) return globals.SupportedAudioTypes.includes(extclean) return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean) } @@ -35,29 +34,33 @@ module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile /** * TODO: Function needs to be re-done - * @param {string} mediaType + * @param {string} mediaType * @param {string[]} paths array of relative file paths * @returns {Record} map of files grouped into potential libarary item dirs */ function groupFilesIntoLibraryItemPaths(mediaType, paths) { // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir var nonMediaFilePaths = [] - var pathsFiltered = paths.map(path => { - return path.startsWith('/') ? path.slice(1) : path - }).filter(path => { - let parsedPath = Path.parse(path) - // Is not in root dir OR is a book media file - if (parsedPath.dir) { - if (!isMediaFile(mediaType, parsedPath.ext, false)) { // Seperate out non-media files - nonMediaFilePaths.push(path) - return false + var pathsFiltered = paths + .map((path) => { + return path.startsWith('/') ? path.slice(1) : path + }) + .filter((path) => { + let parsedPath = Path.parse(path) + // Is not in root dir OR is a book media file + if (parsedPath.dir) { + if (!isMediaFile(mediaType, parsedPath.ext, false)) { + // Seperate out non-media files + nonMediaFilePaths.push(path) + return false + } + return true + } else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) { + // (book media type supports single file audiobooks/ebooks in root dir) + return true } - return true - } else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) { // (book media type supports single file audiobooks/ebooks in root dir) - return true - } - return false - }) + return false + }) // Step 2: Sort by least number of directories pathsFiltered.sort((a, b) => { @@ -69,7 +72,9 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) { // Step 3: Group files in dirs var itemGroup = {} pathsFiltered.forEach((path) => { - var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory + var dirparts = Path.dirname(path) + .split('/') + .filter((p) => !!p && p !== '.') // dirname returns . if no directory var numparts = dirparts.length var _path = '' @@ -82,14 +87,17 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) { var dirpart = dirparts.shift() _path = Path.posix.join(_path, dirpart) - if (itemGroup[_path]) { // Directory already has files, add file + if (itemGroup[_path]) { + // Directory already has files, add file var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) itemGroup[_path].push(relpath) return - } else if (!dirparts.length) { // This is the last directory, create group + } else if (!dirparts.length) { + // This is the last directory, create group itemGroup[_path] = [Path.basename(path)] return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group + } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { + // Next directory is the last and is a CD dir, create group itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] return } @@ -99,7 +107,6 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) { // Step 4: Add in non-media files if they fit into item group if (nonMediaFilePaths.length) { - for (const nonMediaFilePath of nonMediaFilePaths) { const pathDir = Path.dirname(nonMediaFilePath) const filename = Path.basename(nonMediaFilePath) @@ -111,7 +118,8 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) { for (let i = 0; i < numparts; i++) { const dirpart = dirparts.shift() _path = Path.posix.join(_path, dirpart) - if (itemGroup[_path]) { // Directory is a group + if (itemGroup[_path]) { + // Directory is a group const relpath = Path.posix.join(dirparts.join('/'), filename) itemGroup[_path].push(relpath) } else if (!dirparts.length) { @@ -126,31 +134,22 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) { module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths /** - * @param {string} mediaType + * @param {string} mediaType * @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles) - * @param {boolean} [audiobooksOnly=false] + * @param {boolean} [audiobooksOnly=false] * @returns {Record} map of files grouped into potential libarary item dirs */ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly = false) { - // Handle music where every audio file is a library item - if (mediaType === 'music') { - const audioFileGroup = {} - fileItems.filter(i => isMediaFile(mediaType, i.extension, audiobooksOnly)).forEach((item) => { - audioFileGroup[item.path] = item.path - }) - return audioFileGroup - } - // Step 1: Filter out non-book-media files in root dir (with depth of 0) - const itemsFiltered = fileItems.filter(i => { - return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video' || mediaType === 'music') && isMediaFile(mediaType, i.extension, audiobooksOnly)) + const itemsFiltered = fileItems.filter((i) => { + return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension, audiobooksOnly)) }) // Step 2: Seperate media files and other files // - Directories without a media file will not be included const mediaFileItems = [] const otherFileItems = [] - itemsFiltered.forEach(item => { + itemsFiltered.forEach((item) => { if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item) else otherFileItems.push(item) }) @@ -158,7 +157,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly // Step 3: Group audio files in library items const libraryItemGroup = {} mediaFileItems.forEach((item) => { - const dirparts = item.reldirpath.split('/').filter(p => !!p) + const dirparts = item.reldirpath.split('/').filter((p) => !!p) const numparts = dirparts.length let _path = '' @@ -171,14 +170,17 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly const dirpart = dirparts.shift() _path = Path.posix.join(_path, dirpart) - if (libraryItemGroup[_path]) { // Directory already has files, add file + if (libraryItemGroup[_path]) { + // Directory already has files, add file const relpath = Path.posix.join(dirparts.join('/'), item.name) libraryItemGroup[_path].push(relpath) return - } else if (!dirparts.length) { // This is the last directory, create group + } else if (!dirparts.length) { + // This is the last directory, create group libraryItemGroup[_path] = [item.name] return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group + } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { + // Next directory is the last and is a CD dir, create group libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)] return } @@ -196,7 +198,8 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly for (let i = 0; i < numparts; i++) { const dirpart = dirparts.shift() _path = Path.posix.join(_path, dirpart) - if (libraryItemGroup[_path]) { // Directory is audiobook group + if (libraryItemGroup[_path]) { + // Directory is audiobook group const relpath = Path.posix.join(dirparts.join('/'), item.name) libraryItemGroup[_path].push(relpath) return @@ -209,33 +212,35 @@ module.exports.groupFileItemsIntoLibraryItemDirs = groupFileItemsIntoLibraryItem /** * Get LibraryFile from filepath - * @param {string} libraryItemPath - * @param {string[]} files + * @param {string} libraryItemPath + * @param {string[]} files * @returns {import('../objects/files/LibraryFile')} */ function buildLibraryFile(libraryItemPath, files) { - return Promise.all(files.map(async (file) => { - const filePath = Path.posix.join(libraryItemPath, file) - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(filePath, file) - return newLibraryFile - })) + return Promise.all( + files.map(async (file) => { + const filePath = Path.posix.join(libraryItemPath, file) + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(filePath, file) + return newLibraryFile + }) + ) } module.exports.buildLibraryFile = buildLibraryFile /** * Get details parsed from filenames - * - * @param {string} relPath - * @param {boolean} parseSubtitle + * + * @param {string} relPath + * @param {boolean} parseSubtitle * @returns {LibraryItemFilenameMetadata} */ function getBookDataFromDir(relPath, parseSubtitle = false) { const splitDir = relPath.split('/') var folder = splitDir.pop() // Audio files will always be in the directory named for the title - series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series - author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/ + series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series + author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/ // The may contain various other pieces of metadata, these functions extract it. var [folder, asin] = getASIN(folder) @@ -244,7 +249,6 @@ function getBookDataFromDir(relPath, parseSubtitle = false) { var [folder, publishedYear] = getPublishedYear(folder) var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null] - return { title, subtitle, @@ -260,8 +264,8 @@ module.exports.getBookDataFromDir = getBookDataFromDir /** * Extract narrator from folder name - * - * @param {string} folder + * + * @param {string} folder * @returns {[string, string]} [folder, narrator] */ function getNarrator(folder) { @@ -272,7 +276,7 @@ function getNarrator(folder) { /** * Extract series sequence from folder name - * + * * @example * 'Book 2 - Title - Subtitle' * 'Title - Subtitle - Vol 12' @@ -283,8 +287,8 @@ function getNarrator(folder) { * '100 - Book Title' * '6. Title' * '0.5 - Book Title' - * - * @param {string} folder + * + * @param {string} folder * @returns {[string, string]} [folder, sequence] */ function getSequence(folder) { @@ -299,7 +303,9 @@ function getSequence(folder) { if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) { volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString() parts[i] = match.groups.suffix - if (!parts[i]) { parts.splice(i, 1) } + if (!parts[i]) { + parts.splice(i, 1) + } break } } @@ -310,8 +316,8 @@ function getSequence(folder) { /** * Extract published year from folder name - * - * @param {string} folder + * + * @param {string} folder * @returns {[string, string]} [folder, publishedYear] */ function getPublishedYear(folder) { @@ -329,8 +335,8 @@ function getPublishedYear(folder) { /** * Extract subtitle from folder name - * - * @param {string} folder + * + * @param {string} folder * @returns {[string, string]} [folder, subtitle] */ function getSubtitle(folder) { @@ -341,8 +347,8 @@ function getSubtitle(folder) { /** * Extract asin from folder name - * - * @param {string} folder + * + * @param {string} folder * @returns {[string, string]} [folder, asin] */ function getASIN(folder) { @@ -358,8 +364,8 @@ function getASIN(folder) { } /** - * - * @param {string} relPath + * + * @param {string} relPath * @returns {LibraryItemFilenameMetadata} */ function getPodcastDataFromDir(relPath) { @@ -373,10 +379,10 @@ function getPodcastDataFromDir(relPath) { } /** - * - * @param {string} libraryMediaType - * @param {string} folderPath - * @param {string} relPath + * + * @param {string} libraryMediaType + * @param {string} folderPath + * @param {string} relPath * @returns {{ mediaMetadata: LibraryItemFilenameMetadata, relPath: string, path: string}} */ function getDataFromMediaDir(libraryMediaType, folderPath, relPath) { @@ -386,7 +392,8 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath) { if (libraryMediaType === 'podcast') { mediaMetadata = getPodcastDataFromDir(relPath) - } else { // book + } else { + // book mediaMetadata = getBookDataFromDir(relPath, !!global.ServerSettings.scannerParseSubtitle) } From e32c83db63056b4a0463e40865f75052b5e34fcb Mon Sep 17 00:00:00 2001 From: wommy Date: Tue, 3 Sep 2024 19:36:58 -0400 Subject: [PATCH 066/539] npm update nuxt: 2.17.3 -> 2.18.1 --- client/package-lock.json | 5152 +++++++++++++++++++++++--------------- client/package.json | 2 +- 2 files changed, 3120 insertions(+), 2034 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 83586a4e0a..01d41a7e67 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -19,7 +19,7 @@ "fast-average-color": "^9.4.0", "hls.js": "^1.5.7", "libarchive.js": "^1.3.0", - "nuxt": "^2.17.3", + "nuxt": "^2.18.1", "nuxt-socket-io": "^1.1.18", "trix": "^1.3.1", "v-click-outside": "^3.1.2", @@ -29,9 +29,11 @@ "devDependencies": { "@nuxtjs/pwa": "^3.3.5", "autoprefixer": "^10.4.7", - "cypress": "^13.7.3", "postcss": "^8.3.6", "tailwindcss": "^3.4.1" + }, + "optionalDependencies": { + "cypress": "^13.7.3" } }, "node_modules/@alloc/quick-lru": { @@ -47,108 +49,56 @@ } }, "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", + "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", - "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -167,6 +117,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -178,18 +129,20 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", - "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", + "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.6", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "engines": { @@ -197,35 +150,39 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", + "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", - "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.24.7.tgz", + "integrity": "sha512-xZeCVVdwb4MsDBkkyZ64tReWYrLRHlMN72vP7Bdm3OUOuyFZExhsHUUnuWnm2/XOlAJzR0LfPpB56WXZn0X/lA==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -237,23 +194,23 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.7.tgz", - "integrity": "sha512-xCoqR/8+BoNnXOY7RVSgv6X+o7pmT5q1d+gGcRlXYkI+9B31glE4jeejhKVpA04O1AtzOt7OSQ6VYKP5FcRl9g==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.4.tgz", + "integrity": "sha512-ro/bFs3/84MDgDmMwbcHgDa8/E6J3QKNTk4xJJnVeFtGE+tL0K26E3pNxhYz2b67fJpt7Aphw5XcploKXuCvCQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-member-expression-to-functions": "^7.24.8", + "@babel/helper-optimise-call-expression": "^7.24.7", + "@babel/helper-replace-supers": "^7.25.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/traverse": "^7.25.4", "semver": "^6.3.1" }, "engines": { @@ -267,16 +224,18 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", - "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.2.tgz", + "integrity": "sha512-+wqVGP+DFmqwFD3EH6TMTfUNeqDehV3E/dl+Sd54eaXqm17tEUNbEIn4sVivVowbvUpOtIGxdo3GoXyDH9N/9g==", + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-annotate-as-pure": "^7.24.7", "regexpu-core": "^5.3.1", "semver": "^6.3.1" }, @@ -291,14 +250,16 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", + "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", @@ -310,69 +271,42 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", - "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz", + "integrity": "sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.23.0" + "@babel/traverse": "^7.24.8", + "@babel/types": "^7.24.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" }, "engines": { "node": ">=6.9.0" @@ -382,32 +316,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz", + "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", - "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.0.tgz", + "integrity": "sha512-NhavI2eWEIz/H9dbrG0TuOicDhNexze43i5z7lEqwYm0WEZVTwnPpA0EafUTP7+6/W79HWIP2cTe3Z5NiSTVpw==", + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-wrap-function": "^7.22.20" + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-wrap-function": "^7.25.0", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -417,13 +354,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", - "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.0.tgz", + "integrity": "sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==", + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.22.15", - "@babel/helper-optimise-call-expression": "^7.22.5" + "@babel/helper-member-expression-to-functions": "^7.24.8", + "@babel/helper-optimise-call-expression": "^7.24.7", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -433,96 +371,95 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz", + "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", - "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.0.tgz", + "integrity": "sha512-s6Q1ebqutSiZnEjaofc/UKDyC4SbzV5n5SrA2Gq8UawLycr3i04f1dX4OzoQVnexm6aOCh37SQNYlJ/8Ku+PMQ==", + "license": "MIT", "dependencies": { - "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.15", - "@babel/types": "^7.22.19" + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.8.tgz", - "integrity": "sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz", + "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==", + "license": "MIT", "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6" + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -532,6 +469,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -543,6 +481,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -556,6 +495,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", "dependencies": { "color-name": "1.1.3" } @@ -563,12 +503,14 @@ "node_modules/@babel/highlight/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" }, "node_modules/@babel/highlight/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", "engines": { "node": ">=4" } @@ -577,6 +519,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -585,9 +528,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.6" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -595,12 +542,44 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz", + "integrity": "sha512-wUrcsxZg6rqBXG05HG1FPYgsP6EvwF4WpBbxIpWIIYnH8wG0gzx3yZY3dtEHas4sTAOGkbTsc9EGPxwff8lRoA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.0.tgz", + "integrity": "sha512-Bm4bH2qsX880b/3ziJ8KD711LT7z4u8CFudmjqle65AZj/HNUFhEf90dqYv6O86buWvSBmeQDjv0Tn2aF/bIBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz", - "integrity": "sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.0.tgz", + "integrity": "sha512-lXwdNZtTmeVOOFtwM/WDe7yg1PL8sYhRk/XH0FzbR2HDQ0xC+EnQ/JHeoMYSavtU115tnUk0q9CDyq8si+LMAA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -610,13 +589,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz", - "integrity": "sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.7.tgz", + "integrity": "sha512-+izXIbke1T33mY4MSNnrqhPXDz01WYhEf3yF5NbnUtkiNnm+XBZJl3kNfoK6NKmYlz/D07+l2GWVK/QfDkNCuQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.23.3" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -626,12 +606,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.7.tgz", - "integrity": "sha512-LlRT7HgaifEpQA1ZgLVOIJZZFVPWN5iReq/7/JixwBtwcoeVGDBD53ZV28rrsLYOZs1Y/EHhA8N/Z6aazHR8cw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.0.tgz", + "integrity": "sha512-tggFrk1AIShG/RUQbEwt2Tr/E+ObkfwrPjR6BjbRvsx24+PSjK8zrq0GWPNCjo8qpRx4DuJzlcvWJqlm+0h3kw==", + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -645,6 +626,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.", + "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -657,13 +639,14 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.23.7.tgz", - "integrity": "sha512-b1s5JyeMvqj7d9m9KhJNHKc18gEJiSyVzVX3bwbiPalQBQpuvfPh6lA9F7Kk/dWH0TIiXRpB9yicwijY6buPng==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.24.7.tgz", + "integrity": "sha512-RL9GR0pUG5Kc8BUWLNDm2T5OpYwSX15r98I0IkgmRQTXuELq/OynH8xtMTMvTJFjXbMWFVTKtYkTaYQsuAwQlQ==", + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.23.7", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-decorators": "^7.23.3" + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-decorators": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -677,6 +660,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" @@ -693,6 +677,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", @@ -710,6 +695,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.", + "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -726,6 +712,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.", + "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-create-class-features-plugin": "^7.21.0", @@ -743,6 +730,7 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -754,6 +742,7 @@ "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -765,6 +754,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -776,11 +766,12 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.23.3.tgz", - "integrity": "sha512-cf7Niq4/+/juY67E0PbgH0TDhLQ5J7zS8C/Q5FFx+DWyrRa9sUQdTXkjqKu8zGvuqr7vw1muKiukseihU+PJDA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.24.7.tgz", + "integrity": "sha512-Ui4uLJJrRV1lb38zg1yYTmRKmiZLiftDEvZN2iq3kd9kUFU+PttmzTbAFC2ucRk/XJmtek6G23gPsuZbhrT8fQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -793,6 +784,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -804,6 +796,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.3" }, @@ -812,11 +805,12 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz", - "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.25.6.tgz", + "integrity": "sha512-aABl0jHw9bZ2karQ/uUD6XP4u0SG22SJrOHFoL6XB1R7dTovOP4TzTlsxOYC5yQ1pdscVK2JTUnF6QL3ARoAiQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -826,11 +820,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz", - "integrity": "sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.25.6.tgz", + "integrity": "sha512-sXaDXaJN9SNLymBdlWFA+bjzBhFD617ZaFiY13dGt7TVslVvVgA6fkZOP7Ki3IGElC45lwHdOTrCtKZGVAWeLQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -843,6 +838,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -854,6 +850,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -862,11 +859,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", - "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz", + "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -879,6 +877,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -890,6 +889,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -901,6 +901,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -912,6 +913,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -923,6 +925,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -934,6 +937,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -945,6 +949,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -959,6 +964,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -973,6 +979,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -985,11 +992,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz", - "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.7.tgz", + "integrity": "sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -999,14 +1007,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.7.tgz", - "integrity": "sha512-PdxEpL71bJp1byMG0va5gwQcXHxuEYC/BgI/e88mGTtohbZN28O5Yit0Plkkm/dBzCF/BxmbNcses1RH1T+urA==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.4.tgz", + "integrity": "sha512-jz8cV2XDDTqjKPwVPJBIjORVEmSGYhdRa8e5k5+vN+uwcjSrSxUaebBRa4ko1jqNF2uxyg8G6XYk30Jv285xzg==", + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.20", - "@babel/plugin-syntax-async-generators": "^7.8.4" + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-remap-async-to-generator": "^7.25.0", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/traverse": "^7.25.4" }, "engines": { "node": ">=6.9.0" @@ -1016,13 +1025,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", - "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz", + "integrity": "sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA==", + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.20" + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-remap-async-to-generator": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1032,11 +1042,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz", - "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.7.tgz", + "integrity": "sha512-yO7RAz6EsVQDaBH18IDJcMB1HnrUn2FJ/Jslc/WtPPWcjhpUJXU/rjbwmluzp7v/ZzWcEhTMXELnnsz8djWDwQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1046,11 +1057,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz", - "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.0.tgz", + "integrity": "sha512-yBQjYoOjXlFv9nlXb3f1casSHOZkWr29NX+zChVanLg5Nc157CrbEX9D7hxxtTpuFy7Q0YzmmWfJxzvps4kXrQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -1060,12 +1072,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz", - "integrity": "sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.4.tgz", + "integrity": "sha512-nZeZHyCWPfjkdU5pA/uHiTaDAFUEqkpzf1YoQT2NeSynCGYq9rxfyI3XpQbfx/a0hSnFH6TGlEXvae5Vi7GD8g==", + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-class-features-plugin": "^7.25.4", + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -1075,12 +1088,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", - "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.7.tgz", + "integrity": "sha512-HMXK3WbBPpZQufbMG4B46A90PkuuhN9vBCb5T8+VAHqvAqvcLi+2cKoukcpmUYkszLhScU3l1iudhrks3DggRQ==", + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-class-static-block": "^7.14.5" }, "engines": { @@ -1091,17 +1105,16 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.8.tgz", - "integrity": "sha512-yAYslGsY1bX6Knmg46RjiCiNSwJKv2IUC8qOdYKqMMr0491SXFhcHqOdRDeCRohOOIzwN/90C6mQ9qAKgrP7dg==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", - "@babel/helper-split-export-declaration": "^7.22.6", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.4.tgz", + "integrity": "sha512-oexUfaQle2pF/b6E0dwsxQtAol9TLSO88kQvym6HHBWFliV2lGdrPieX+WgMRLSJDVzdYywk7jXbLPuO2KLTLg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-replace-supers": "^7.25.0", + "@babel/traverse": "^7.25.4", "globals": "^11.1.0" }, "engines": { @@ -1112,12 +1125,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", - "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz", + "integrity": "sha512-25cS7v+707Gu6Ds2oY6tCkUwsJ9YIDbggd9+cu9jzzDgiNq7hR/8dkzxWfKWnTic26vsI3EsCXNd4iEB6e8esQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/template": "^7.22.15" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/template": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1127,11 +1141,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz", - "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.8.tgz", + "integrity": "sha512-36e87mfY8TnRxc7yc6M9g9gOB7rKgSahqkIKwLpz4Ppk2+zC2Cy1is0uwtuSG6AE4zlTOUa+7JGz9jCJGLqQFQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -1141,12 +1156,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz", - "integrity": "sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.7.tgz", + "integrity": "sha512-ZOA3W+1RRTSWvyqcMJDLqbchh7U4NRGqwRfFSVbOLS/ePIP4vHB5e8T8eXcuqyN1QkgKyj5wuW0lcS85v4CrSw==", + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1156,11 +1172,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz", - "integrity": "sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.7.tgz", + "integrity": "sha512-JdYfXyCRihAe46jUIliuL2/s0x0wObgwwiGxw/UbgJBr20gQBThrokO4nYKgWkD7uBaqM7+9x5TU7NkExZJyzw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1169,12 +1186,29 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.0.tgz", + "integrity": "sha512-YLpb4LlYSc3sCUa35un84poXoraOiQucUTTu8X1j18JV+gNa8E0nyUf/CjZ171IRGr4jEguF+vzJU66QZhn29g==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.0", + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz", - "integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.7.tgz", + "integrity": "sha512-sc3X26PhZQDb3JhORmakcbvkeInvxz+A8oda99lj7J60QRuPZvNAk9wQlTBS1ZynelDrDmTU4pw1tyc5d5ZMUg==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3" }, "engines": { @@ -1185,12 +1219,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz", - "integrity": "sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.7.tgz", + "integrity": "sha512-Rqe/vSc9OYgDajNIK35u7ot+KeCoetqQYFXM4Epf7M7ez3lWlOjrDjrwMei6caCVhfdw+mIKD4cgdGNy5JQotQ==", + "license": "MIT", "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1200,11 +1235,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz", - "integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.7.tgz", + "integrity": "sha512-v0K9uNYsPL3oXZ/7F9NNIbAj2jv1whUEtyA6aujhekLs56R++JDQuzRcP2/z4WX5Vg/c5lE9uWZA0/iUoFhLTA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-export-namespace-from": "^7.8.3" }, "engines": { @@ -1215,12 +1251,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz", - "integrity": "sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.7.tgz", + "integrity": "sha512-wo9ogrDG1ITTTBsy46oGiN1dS9A7MROBTcYsfS8DtsImMkHk9JXJ3EWQM6X2SUw4x80uGPlwj0o00Uoc6nEE3g==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1230,13 +1267,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz", - "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==", + "version": "7.25.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.1.tgz", + "integrity": "sha512-TVVJVdW9RKMNgJJlLtHsKDTydjZAbwIsn6ySBPQaEAUU5+gVvlJt/9nRmqVbsV/IBanRjzWoaAQKLoamWVOUuA==", + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-compilation-targets": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.1" }, "engines": { "node": ">=6.9.0" @@ -1246,11 +1284,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz", - "integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.7.tgz", + "integrity": "sha512-2yFnBGDvRuxAaE/f0vfBKvtnvvqU8tGpMHqMNpTN2oWMKIR3NqFkjaAgGwawhqK/pIN2T3XdjGPdaG0vDhOBGw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-json-strings": "^7.8.3" }, "engines": { @@ -1261,11 +1300,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz", - "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.2.tgz", + "integrity": "sha512-HQI+HcTbm9ur3Z2DkO+jgESMAMcYLuN/A7NRw9juzxAezN9AvqvUTnpKP/9kkYANz6u7dFlAyOu44ejuGySlfw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -1275,11 +1315,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz", - "integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.7.tgz", + "integrity": "sha512-4D2tpwlQ1odXmTEIFWy9ELJcZHqrStlzK/dAOWYyxX3zT0iXQB6banjgeOJQXzEc4S0E0a5A+hahxPaEFYftsw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" }, "engines": { @@ -1290,11 +1331,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz", - "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.7.tgz", + "integrity": "sha512-T/hRC1uqrzXMKLQ6UCwMT85S3EvqaBXDGf0FaMf4446Qx9vKwlghvee0+uuZcDUCZU5RuNi4781UQ7R308zzBw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1304,12 +1346,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz", - "integrity": "sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.7.tgz", + "integrity": "sha512-9+pB1qxV3vs/8Hdmz/CulFB8w2tuu6EB94JZFsjdqxQokwGa9Unap7Bo2gGBGIvPmDIVvQrom7r5m/TCDMURhg==", + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1319,13 +1362,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", - "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz", + "integrity": "sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA==", + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-simple-access": "^7.22.5" + "@babel/helper-module-transforms": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-simple-access": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1335,14 +1379,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz", - "integrity": "sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.0.tgz", + "integrity": "sha512-YPJfjQPDXxyQWg/0+jHKj1llnY5f/R6a0p/vP4lPymxLu7Lvl4k2WMitqi08yxwQcCVUUdG9LCUj4TNEgAp3Jw==", + "license": "MIT", "dependencies": { - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-transforms": "^7.25.0", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -1352,12 +1397,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz", - "integrity": "sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.7.tgz", + "integrity": "sha512-3aytQvqJ/h9z4g8AsKPLvD4Zqi2qT+L3j7XoFFu1XBlZWEl2/1kWnhmAbxpLgPrHSY0M6UA02jyTiwUVtiKR6A==", + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1367,12 +1413,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", - "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.24.7.tgz", + "integrity": "sha512-/jr7h/EWeJtk1U/uz2jlsCioHkZk1JJZVcc8oQsJ1dUlaJD83f4/6Zeh2aHt9BIFokHIsSeDfhUmju0+1GPd6g==", + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1382,11 +1429,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz", - "integrity": "sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.7.tgz", + "integrity": "sha512-RNKwfRIXg4Ls/8mMTza5oPF5RkOW8Wy/WgMAp1/F1yZ8mMbtwXW+HDoJiOsagWrAhI5f57Vncrmr9XeT4CVapA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1396,11 +1444,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz", - "integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz", + "integrity": "sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" }, "engines": { @@ -1411,11 +1460,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz", - "integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.7.tgz", + "integrity": "sha512-e6q1TiVUzvH9KRvicuxdBTUj4AdKSRwzIyFFnfnezpCfP2/7Qmbb8qbU2j7GODbl4JMkblitCQjKYUaX/qkkwA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-numeric-separator": "^7.10.4" }, "engines": { @@ -1426,15 +1476,15 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz", - "integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.7.tgz", + "integrity": "sha512-4QrHAr0aXQCEFni2q4DqKLD31n2DL+RxcwnNjDFkSG0eNQ/xCavnRkfCUjsyqGC2OviNJvZOF/mQqZBw7i2C5Q==", + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.23.3", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.23.3" + "@babel/plugin-transform-parameters": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1444,12 +1494,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz", - "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.7.tgz", + "integrity": "sha512-A/vVLwN6lBrMFmMDmPPz0jnE6ZGx7Jq7d6sT/Ev4H65RER6pZ+kczlf1DthF5N0qaPHBsI7UXiE8Zy66nmAovg==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-replace-supers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1459,11 +1510,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz", - "integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.7.tgz", + "integrity": "sha512-uLEndKqP5BfBbC/5jTwPxLh9kqPWWgzN/f8w6UwAIirAEqiIVJWWY312X72Eub09g5KF9+Zn7+hT7sDxmhRuKA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" }, "engines": { @@ -1474,12 +1526,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz", - "integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz", + "integrity": "sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, "engines": { @@ -1490,11 +1543,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz", - "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.7.tgz", + "integrity": "sha512-yGWW5Rr+sQOhK0Ot8hjDJuxU3XLRQGflvT4lhlSY0DFvdb3TwKaY26CJzHtYllU0vT9j58hc37ndFPsqT1SrzA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1504,12 +1558,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz", - "integrity": "sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.4.tgz", + "integrity": "sha512-ao8BG7E2b/URaUQGqN3Tlsg+M3KlHY6rJ1O1gXAEUnZoyNQnvKyH87Kfg+FoxSeyWUB8ISZZsC91C44ZuBFytw==", + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-class-features-plugin": "^7.25.4", + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -1519,13 +1574,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz", - "integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz", + "integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-private-property-in-object": "^7.14.5" }, "engines": { @@ -1536,11 +1592,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz", - "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.7.tgz", + "integrity": "sha512-EMi4MLQSHfd2nrCqQEWxFdha2gBCqU4ZcCng4WBGZ5CJL4bBRW0ptdqqDdeirGZcpALazVVNJqRmsO8/+oNCBA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1550,11 +1607,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz", - "integrity": "sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.7.tgz", + "integrity": "sha512-lq3fvXPdimDrlg6LWBoqj+r/DEWgONuwjuOuQCSYgRroXDH/IdM1C0IZf59fL5cHLpjEH/O6opIRBbqv7ELnuA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "regenerator-transform": "^0.15.2" }, "engines": { @@ -1565,11 +1623,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz", - "integrity": "sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.7.tgz", + "integrity": "sha512-0DUq0pHcPKbjFZCfTss/pGkYMfy3vFWydkUBd9r0GHpIyfs2eCDENvqadMycRS9wZCXR41wucAfJHJmwA0UmoQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1579,15 +1638,16 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.23.7.tgz", - "integrity": "sha512-fa0hnfmiXc9fq/weK34MUV0drz2pOL/vfKWvN7Qw127hiUPabFCUMgAbYWcchRzMJit4o5ARsK/s+5h0249pLw==", - "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "babel-plugin-polyfill-corejs2": "^0.4.7", - "babel-plugin-polyfill-corejs3": "^0.8.7", - "babel-plugin-polyfill-regenerator": "^0.5.4", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.25.4.tgz", + "integrity": "sha512-8hsyG+KUYGY0coX6KUCDancA0Vw225KJ2HJO0yCNr1vq5r+lJTleDaJf0K7iOhjw4SWhu03TMBzYTJ9krmzULQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.8", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", "semver": "^6.3.1" }, "engines": { @@ -1601,16 +1661,18 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz", - "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz", + "integrity": "sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1620,12 +1682,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz", - "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.7.tgz", + "integrity": "sha512-x96oO0I09dgMDxJaANcRyD4ellXFLLiWhuwDxKZX5g2rWP1bTPkBSwCYv96VDXVT1bD9aPj8tppr5ITIh8hBng==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1635,11 +1698,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz", - "integrity": "sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.7.tgz", + "integrity": "sha512-kHPSIJc9v24zEml5geKg9Mjx5ULpfncj0wRpYtxbvKyTtHCYDkVE3aHQ03FrpEo4gEe2vrJJS1Y9CJTaThA52g==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1649,11 +1713,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz", - "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz", + "integrity": "sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1663,11 +1728,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz", - "integrity": "sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.8.tgz", + "integrity": "sha512-adNTUpDCVnmAE58VEqKlAA6ZBlNkMnWD0ZcW76lyNFN3MJniyGFZfNwERVk8Ap56MCnXztmDr19T4mPTztcuaw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -1677,11 +1743,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz", - "integrity": "sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.7.tgz", + "integrity": "sha512-U3ap1gm5+4edc2Q/P+9VrBNhGkfnf+8ZqppY71Bo/pzZmXhhLdqgaUl6cuB07O1+AQJtCLfaOmswiNbSQ9ivhw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1691,12 +1758,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz", - "integrity": "sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.7.tgz", + "integrity": "sha512-uH2O4OV5M9FZYQrwc7NdVmMxQJOCCzFeYudlZSzUAHRFeOujQefa92E74TQDVskNHCzOXoigEuoyzHDhaEaK5w==", + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1706,12 +1774,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz", - "integrity": "sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.7.tgz", + "integrity": "sha512-hlQ96MBZSAXUq7ltkjtu3FJCCSMx/j629ns3hA3pXnBXjanNP0LHi+JpPeA81zaWgVK1VGH95Xuy7u0RyQ8kMg==", + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1721,12 +1790,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz", - "integrity": "sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.4.tgz", + "integrity": "sha512-qesBxiWkgN1Q+31xUE9RcMk79eOXXDCv6tfyGMRSs4RGlioSg2WVyQAm07k726cSE56pa+Kb0y9epX2qaXzTvA==", + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.25.2", + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -1736,25 +1806,28 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.8.tgz", - "integrity": "sha512-lFlpmkApLkEP6woIKprO6DO60RImpatTQKtz4sUcDjVcK8M8mQ4sZsuxaTMNOZf0sqAq/ReYW1ZBHnOQwKpLWA==", - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.7", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.4.tgz", + "integrity": "sha512-W9Gyo+KmcxjGahtt3t9fb14vFRWvPpu5pT6GBlovAK6BTBcxgjfVMSQCfJl4oi35ODrxP6xx2Wr8LNST57Mraw==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.4", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-validator-option": "^7.24.8", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.3", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.0", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.0", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.0", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.23.3", - "@babel/plugin-syntax-import-attributes": "^7.23.3", + "@babel/plugin-syntax-import-assertions": "^7.24.7", + "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", @@ -1766,59 +1839,60 @@ "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.23.3", - "@babel/plugin-transform-async-generator-functions": "^7.23.7", - "@babel/plugin-transform-async-to-generator": "^7.23.3", - "@babel/plugin-transform-block-scoped-functions": "^7.23.3", - "@babel/plugin-transform-block-scoping": "^7.23.4", - "@babel/plugin-transform-class-properties": "^7.23.3", - "@babel/plugin-transform-class-static-block": "^7.23.4", - "@babel/plugin-transform-classes": "^7.23.8", - "@babel/plugin-transform-computed-properties": "^7.23.3", - "@babel/plugin-transform-destructuring": "^7.23.3", - "@babel/plugin-transform-dotall-regex": "^7.23.3", - "@babel/plugin-transform-duplicate-keys": "^7.23.3", - "@babel/plugin-transform-dynamic-import": "^7.23.4", - "@babel/plugin-transform-exponentiation-operator": "^7.23.3", - "@babel/plugin-transform-export-namespace-from": "^7.23.4", - "@babel/plugin-transform-for-of": "^7.23.6", - "@babel/plugin-transform-function-name": "^7.23.3", - "@babel/plugin-transform-json-strings": "^7.23.4", - "@babel/plugin-transform-literals": "^7.23.3", - "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", - "@babel/plugin-transform-member-expression-literals": "^7.23.3", - "@babel/plugin-transform-modules-amd": "^7.23.3", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-modules-systemjs": "^7.23.3", - "@babel/plugin-transform-modules-umd": "^7.23.3", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.23.3", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", - "@babel/plugin-transform-numeric-separator": "^7.23.4", - "@babel/plugin-transform-object-rest-spread": "^7.23.4", - "@babel/plugin-transform-object-super": "^7.23.3", - "@babel/plugin-transform-optional-catch-binding": "^7.23.4", - "@babel/plugin-transform-optional-chaining": "^7.23.4", - "@babel/plugin-transform-parameters": "^7.23.3", - "@babel/plugin-transform-private-methods": "^7.23.3", - "@babel/plugin-transform-private-property-in-object": "^7.23.4", - "@babel/plugin-transform-property-literals": "^7.23.3", - "@babel/plugin-transform-regenerator": "^7.23.3", - "@babel/plugin-transform-reserved-words": "^7.23.3", - "@babel/plugin-transform-shorthand-properties": "^7.23.3", - "@babel/plugin-transform-spread": "^7.23.3", - "@babel/plugin-transform-sticky-regex": "^7.23.3", - "@babel/plugin-transform-template-literals": "^7.23.3", - "@babel/plugin-transform-typeof-symbol": "^7.23.3", - "@babel/plugin-transform-unicode-escapes": "^7.23.3", - "@babel/plugin-transform-unicode-property-regex": "^7.23.3", - "@babel/plugin-transform-unicode-regex": "^7.23.3", - "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoped-functions": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-class-static-block": "^7.24.7", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-dotall-regex": "^7.24.7", + "@babel/plugin-transform-duplicate-keys": "^7.24.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.0", + "@babel/plugin-transform-dynamic-import": "^7.24.7", + "@babel/plugin-transform-exponentiation-operator": "^7.24.7", + "@babel/plugin-transform-export-namespace-from": "^7.24.7", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-json-strings": "^7.24.7", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-member-expression-literals": "^7.24.7", + "@babel/plugin-transform-modules-amd": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-modules-systemjs": "^7.25.0", + "@babel/plugin-transform-modules-umd": "^7.24.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-new-target": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-object-super": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.25.4", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-property-literals": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-reserved-words": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-template-literals": "^7.24.7", + "@babel/plugin-transform-typeof-symbol": "^7.24.8", + "@babel/plugin-transform-unicode-escapes": "^7.24.7", + "@babel/plugin-transform-unicode-property-regex": "^7.24.7", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.4", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.7", - "babel-plugin-polyfill-corejs3": "^0.8.7", - "babel-plugin-polyfill-regenerator": "^0.5.4", - "core-js-compat": "^3.31.0", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.37.1", "semver": "^6.3.1" }, "engines": { @@ -1832,6 +1906,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "license": "MIT", "engines": { "node": ">=6.9.0" }, @@ -1843,6 +1918,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -1851,6 +1927,7 @@ "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", @@ -1863,12 +1940,14 @@ "node_modules/@babel/regjsgen": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "license": "MIT" }, "node_modules/@babel/runtime": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", - "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1877,31 +1956,30 @@ } }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", - "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", - "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1910,12 +1988,13 @@ } }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, "engines": { @@ -1926,16 +2005,15 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, "optional": true, "engines": { "node": ">=0.1.90" } }, "node_modules/@csstools/cascade-layer-name-parser": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.7.tgz", - "integrity": "sha512-9J4aMRJ7A2WRjaRLvsMeWrL69FmEuijtiW1XlK/sG+V0UJiHVYUyvj9mY4WAXfU/hGIiGOgL8e0jJcRyaZTjDQ==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.13.tgz", + "integrity": "sha512-MX0yLTwtZzr82sQ0zOjqimpZbzjMaK/h2pmlrLK7DCzlmiZLYFpoO94WmN1akRVo6ll/TdpHb53vihHLUMyvng==", "funding": [ { "type": "github", @@ -1946,18 +2024,19 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, "node_modules/@csstools/color-helpers": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-4.0.0.tgz", - "integrity": "sha512-wjyXB22/h2OvxAr3jldPB7R7kjTUEzopvjitS8jWtyd8fN6xJ8vy1HnHu0ZNfEkqpBJgQ76Q+sBDshWcMvTa/w==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-4.2.1.tgz", + "integrity": "sha512-CEypeeykO9AN7JWkr1OEOQb0HRzZlPWGwV0Ya6DuVgFdDi6g3ma/cPZ5ZPZM4AWQikDpq/0llnGGlIL+j8afzw==", "funding": [ { "type": "github", @@ -1968,14 +2047,15 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" } }, "node_modules/@csstools/css-calc": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-1.1.6.tgz", - "integrity": "sha512-YHPAuFg5iA4qZGzMzvrQwzkvJpesXXyIUyaONflQrjtHB+BcFFbgltJkIkb31dMGO4SE9iZFA4HYpdk7+hnYew==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-1.2.4.tgz", + "integrity": "sha512-tfOuvUQeo7Hz+FcuOd3LfXVp+342pnWUJ7D2y8NUpu1Ww6xnTbHLpz018/y6rtbHifJ3iIEf9ttxXd8KG7nL0Q==", "funding": [ { "type": "github", @@ -1986,18 +2066,19 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, "node_modules/@csstools/css-color-parser": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.5.1.tgz", - "integrity": "sha512-x+SajGB2paGrTjPOUorGi8iCztF008YMKXTn+XzGVDBEIVJ/W1121pPerpneJYGOe1m6zWLPLnzOPaznmQxKFw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-2.0.5.tgz", + "integrity": "sha512-lRZSmtl+DSjok3u9hTWpmkxFZnz7stkbZxzKc08aDUsdrWwhSgWo8yq9rq9DaFUtbAyAq2xnH92fj01S+pwIww==", "funding": [ { "type": "github", @@ -2008,22 +2089,23 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^4.0.0", - "@csstools/css-calc": "^1.1.6" + "@csstools/color-helpers": "^4.2.1", + "@csstools/css-calc": "^1.2.4" }, "engines": { "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.5.0.tgz", - "integrity": "sha512-abypo6m9re3clXA00eu5syw+oaPHbJTPapu9C4pzNsJ4hdZDzushT50Zhu+iIYXgEe1CxnRMn7ngsbV+MLrlpQ==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", + "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", "funding": [ { "type": "github", @@ -2034,17 +2116,18 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-tokenizer": "^2.4.1" } }, "node_modules/@csstools/css-tokenizer": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.2.3.tgz", - "integrity": "sha512-pp//EvZ9dUmGuGtG1p+n17gTHEOqu9jO+FiCUjNN3BDmyhdA2Jq9QsVeR7K8/2QCK17HSsioPlTW9ZkzoWb3Lg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", + "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", "funding": [ { "type": "github", @@ -2055,14 +2138,15 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": "^14 || ^16 || >=18" } }, "node_modules/@csstools/media-query-list-parser": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.7.tgz", - "integrity": "sha512-lHPKJDkPUECsyAvD60joYfDmp8UERYxHGkFfyLJFTVK/ERJe0sVlIFLXU5XFxdjNDTerp5L4KeaKG+Z5S94qxQ==", + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", + "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", "funding": [ { "type": "github", @@ -2073,18 +2157,19 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, "node_modules/@csstools/postcss-cascade-layers": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.2.tgz", - "integrity": "sha512-PqM+jvg5T2tB4FHX+akrMGNWAygLupD4FNUjcv4PSvtVuWZ6ISxuo37m4jFGU7Jg3rCfloGzKd0+xfr5Ec3vZQ==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.6.tgz", + "integrity": "sha512-Xt00qGAQyqAODFiFEJNkTpSUz5VfYqnDLECdlA/Vv17nl/OIV5QfTRHGAXrBGG5YcJyHpJ+GF9gF/RZvOQz4oA==", "funding": [ { "type": "github", @@ -2095,8 +2180,9 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/selector-specificity": "^3.0.1", + "@csstools/selector-specificity": "^3.1.1", "postcss-selector-parser": "^6.0.13" }, "engines": { @@ -2107,9 +2193,9 @@ } }, "node_modules/@csstools/postcss-color-function": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.9.tgz", - "integrity": "sha512-6Hbkw/4k73UH121l4LG+LNLKSvrfHqk3GHHH0A6/iFlD0xGmsWAr80Jd0VqXjfYbUTOGmJTOMMoxv3jvNxt1uw==", + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.19.tgz", + "integrity": "sha512-d1OHEXyYGe21G3q88LezWWx31ImEDdmINNDy0LyLNN9ChgN2bPxoubUPiHf9KmwypBMaHmNcMuA/WZOKdZk/Lg==", "funding": [ { "type": "github", @@ -2120,11 +2206,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/postcss-progressive-custom-properties": "^3.0.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2134,9 +2222,9 @@ } }, "node_modules/@csstools/postcss-color-mix-function": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.9.tgz", - "integrity": "sha512-fs1SOWJ/44DQSsDeJP+rxAkP2MYkCg6K4ZB8qJwFku2EjurgCAPiPZJvC6w94T1hBBinJwuMfT9qvvvniXyVgw==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.19.tgz", + "integrity": "sha512-mLvQlMX+keRYr16AuvuV8WYKUwF+D0DiCqlBdvhQ0KYEtcQl9/is9Ssg7RcIys8x0jIn2h1zstS4izckdZj9wg==", "funding": [ { "type": "github", @@ -2147,11 +2235,41 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/postcss-progressive-custom-properties": "^3.0.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-content-alt-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-1.0.0.tgz", + "integrity": "sha512-SkHdj7EMM/57GVvSxSELpUg7zb5eAndBeuvGwFzYtU06/QXJ/h9fuK7wO5suteJzGhm3GDF/EWPCdWV2h1IGHQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2161,9 +2279,9 @@ } }, "node_modules/@csstools/postcss-exponential-functions": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-1.0.3.tgz", - "integrity": "sha512-IfGtEg3eC4b8Nd/kPgO3SxgKb33YwhHVsL0eJ3UYihx6fzzAiZwNbWmVW9MZTQjZ5GacgKxa4iAHikGvpwuIjw==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-1.0.9.tgz", + "integrity": "sha512-x1Avr15mMeuX7Z5RJUl7DmjhUtg+Amn5DZRD0fQ2TlTFTcJS8U1oxXQ9e5mA62S2RJgUU6db20CRoJyDvae2EQ==", "funding": [ { "type": "github", @@ -2174,10 +2292,11 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^1.1.6", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-calc": "^1.2.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2187,9 +2306,9 @@ } }, "node_modules/@csstools/postcss-font-format-keywords": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-3.0.1.tgz", - "integrity": "sha512-D1lcG2sfotTq6yBEOMV3myFxJLT10F3DLYZJMbiny5YToqzHWodZen8WId3UTimm0mEHitXqAUNL5jdd6RzVdA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-3.0.2.tgz", + "integrity": "sha512-E0xz2sjm4AMCkXLCFvI/lyl4XO6aN1NCSMMVEOngFDJ+k2rDwfr6NDjWljk1li42jiLNChVX+YFnmfGCigZKXw==", "funding": [ { "type": "github", @@ -2200,7 +2319,9 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { + "@csstools/utilities": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -2211,9 +2332,9 @@ } }, "node_modules/@csstools/postcss-gamut-mapping": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-1.0.2.tgz", - "integrity": "sha512-zf9KHGM2PTuJEm4ZYg4DTmzCir38EbZBzlMPMbA4jbhLDqXHkqwnQ+Z5+UNrU8y6seVu5B4vzZmZarTFQwe+Ig==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-1.0.11.tgz", + "integrity": "sha512-KrHGsUPXRYxboXmJ9wiU/RzDM7y/5uIefLWKFSc36Pok7fxiPyvkSHO51kh+RLZS1W5hbqw9qaa6+tKpTSxa5g==", "funding": [ { "type": "github", @@ -2224,10 +2345,11 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2237,9 +2359,9 @@ } }, "node_modules/@csstools/postcss-gradients-interpolation-method": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.9.tgz", - "integrity": "sha512-PSqR6QH7h3ggOl8TsoH73kbwYTKVQjAJauGg6nDKwaGfi5IL5StV//ehrv1C7HuPsHixMTc9YoAuuv1ocT20EQ==", + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.20.tgz", + "integrity": "sha512-ZFl2JBHano6R20KB5ZrB8KdPM2pVK0u+/3cGQ2T8VubJq982I2LSOvQ4/VtxkAXjkPkk1rXt4AD1ni7UjTZ1Og==", "funding": [ { "type": "github", @@ -2250,11 +2372,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/postcss-progressive-custom-properties": "^3.0.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2264,9 +2388,9 @@ } }, "node_modules/@csstools/postcss-hwb-function": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.8.tgz", - "integrity": "sha512-CRQEG372Hivmt17rm/Ho22hBQI9K/a6grzGQ21Zwc7dyspmyG0ibmPIW8hn15vJmXqWGeNq7S+L2b8/OrU7O5A==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.18.tgz", + "integrity": "sha512-3ifnLltR5C7zrJ+g18caxkvSRnu9jBBXCYgnBznRjxm6gQJGnnCO9H6toHfywNdNr/qkiVf2dymERPQLDnjLRQ==", "funding": [ { "type": "github", @@ -2277,10 +2401,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2290,9 +2417,9 @@ } }, "node_modules/@csstools/postcss-ic-unit": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-3.0.3.tgz", - "integrity": "sha512-MpcmIL0/uMm/cFWh5V/9nbKKJ7jRr2qTYW5Q6zoE6HZ6uzOBJr2KRERv5/x8xzEBQ1MthDT7iP1EBp9luSQy7g==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-3.0.7.tgz", + "integrity": "sha512-YoaNHH2wNZD+c+rHV02l4xQuDpfR8MaL7hD45iJyr+USwvr0LOheeytJ6rq8FN6hXBmEeoJBeXXgGmM8fkhH4g==", "funding": [ { "type": "github", @@ -2303,8 +2430,10 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^3.0.3", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -2328,6 +2457,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" }, @@ -2336,9 +2466,9 @@ } }, "node_modules/@csstools/postcss-is-pseudo-class": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-4.0.4.tgz", - "integrity": "sha512-vTVO/uZixpTVAOQt3qZRUFJ/K1L03OfNkeJ8sFNDVNdVy/zW0h1L5WT7HIPMDUkvSrxQkFaCCybTZkUP7UESlQ==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-4.0.8.tgz", + "integrity": "sha512-0aj591yGlq5Qac+plaWCbn5cpjs5Sh0daovYUKJUOMjIp70prGH/XPLp7QjxtbFXz3CTvb0H9a35dpEuIuUi3Q==", "funding": [ { "type": "github", @@ -2349,8 +2479,9 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/selector-specificity": "^3.0.1", + "@csstools/selector-specificity": "^3.1.1", "postcss-selector-parser": "^6.0.13" }, "engines": { @@ -2360,6 +2491,34 @@ "postcss": "^8.4" } }, + "node_modules/@csstools/postcss-light-dark-function": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-1.0.8.tgz", + "integrity": "sha512-x0UtpCyVnERsplUeoaY6nEtp1HxTf4lJjoK/ULEm40DraqFfUdUSt76yoOyX5rGY6eeOUOkurHyYlFHVKv/pew==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, "node_modules/@csstools/postcss-logical-float-and-clear": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-2.0.1.tgz", @@ -2374,6 +2533,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" }, @@ -2395,6 +2555,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" }, @@ -2416,6 +2577,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" }, @@ -2437,6 +2599,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2448,9 +2611,9 @@ } }, "node_modules/@csstools/postcss-logical-viewport-units": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-2.0.5.tgz", - "integrity": "sha512-2fjSamKN635DSW6fEoyNd2Bkpv3FVblUpgk5cpghIgPW1aDHZE2SYfZK5xQALvjMYZVjfqsD5EbXA7uDVBQVQA==", + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-2.0.11.tgz", + "integrity": "sha512-ElITMOGcjQtvouxjd90WmJRIw1J7KMP+M+O87HaVtlgOOlDt1uEPeTeii8qKGe2AiedEp0XOGIo9lidbiU2Ogg==", "funding": [ { "type": "github", @@ -2461,8 +2624,10 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2472,9 +2637,9 @@ } }, "node_modules/@csstools/postcss-media-minmax": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-1.1.2.tgz", - "integrity": "sha512-7qTRTJxW96u2yiEaTep1+8nto1O/rEDacewKqH+Riq5E6EsHTOmGHxkB4Se5Ic5xgDC4I05lLZxzzxnlnSypxA==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-1.1.8.tgz", + "integrity": "sha512-KYQCal2i7XPNtHAUxCECdrC7tuxIWQCW+s8eMYs5r5PaAiVTeKwlrkRS096PFgojdNCmHeG0Cb7njtuNswNf+w==", "funding": [ { "type": "github", @@ -2485,11 +2650,12 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/css-calc": "^1.1.6", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/media-query-list-parser": "^2.1.7" + "@csstools/css-calc": "^1.2.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/media-query-list-parser": "^2.1.13" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2499,9 +2665,9 @@ } }, "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-2.0.5.tgz", - "integrity": "sha512-XHMPasWYPWa9XaUHXU6Iq0RLfoAI+nvGTPj51hOizNsHaAyFiq2SL4JvF1DU8lM6B70+HVzKM09Isbyrr755Bw==", + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-2.0.11.tgz", + "integrity": "sha512-YD6jrib20GRGQcnOu49VJjoAnQ/4249liuz7vTpy/JfgqQ1Dlc5eD4HPUMNLOw9CWey9E6Etxwf/xc/ZF8fECA==", "funding": [ { "type": "github", @@ -2512,10 +2678,11 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/media-query-list-parser": "^2.1.7" + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/media-query-list-parser": "^2.1.13" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2525,9 +2692,9 @@ } }, "node_modules/@csstools/postcss-nested-calc": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-3.0.1.tgz", - "integrity": "sha512-bwwababZpWRm0ByHaWBxTsDGTMhZKmtUNl3Wt0Eom8AY7ORgXx5qF9SSk1vEFrCi+HOfJT6M6W5KPgzXuQNRwQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-3.0.2.tgz", + "integrity": "sha512-ySUmPyawiHSmBW/VI44+IObcKH0v88LqFe0d09Sb3w4B1qjkaROc6d5IA3ll9kjD46IIX/dbO5bwFN/swyoyZA==", "funding": [ { "type": "github", @@ -2538,7 +2705,9 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { + "@csstools/utilities": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -2562,6 +2731,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2573,9 +2743,9 @@ } }, "node_modules/@csstools/postcss-oklab-function": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.9.tgz", - "integrity": "sha512-l639gpcBfL3ogJe+og1M5FixQn8iGX8+29V7VtTSCUB37VzpzOC05URfde7INIdiJT65DkHzgdJ64/QeYggU8A==", + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.19.tgz", + "integrity": "sha512-e3JxXmxjU3jpU7TzZrsNqSX4OHByRC3XjItV3Ieo/JEQmLg5rdOL4lkv/1vp27gXemzfNt44F42k/pn0FpE21Q==", "funding": [ { "type": "github", @@ -2586,11 +2756,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/postcss-progressive-custom-properties": "^3.0.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2600,9 +2772,9 @@ } }, "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-3.0.3.tgz", - "integrity": "sha512-WipTVh6JTMQfeIrzDV4wEPsV9NTzMK2jwXxyH6CGBktuWdivHnkioP/smp1x/0QDPQyx7NTS14RB+GV3zZZYEw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-3.3.0.tgz", + "integrity": "sha512-W2oV01phnILaRGYPmGFlL2MT/OgYjQDrL9sFlbdikMFi6oQkFki9B86XqEWR7HCsTZFVq7dbzr/o71B75TKkGg==", "funding": [ { "type": "github", @@ -2613,6 +2785,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2624,9 +2797,9 @@ } }, "node_modules/@csstools/postcss-relative-color-syntax": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.9.tgz", - "integrity": "sha512-2UoaRd2iIuzUGtYgteN5fJ0s+OfCiV7PvCnw8MCh3om8+SeVinfG8D5sqBOvImxFVfrp6k60XF5RFlH6oc//fg==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.19.tgz", + "integrity": "sha512-MxUMSNvio1WwuS6WRLlQuv6nNPXwIWUFzBBAvL/tBdWfiKjiJnAa6eSSN5gtaacSqUkQ/Ce5Z1OzLRfeaWhADA==", "funding": [ { "type": "github", @@ -2637,11 +2810,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/postcss-progressive-custom-properties": "^3.0.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2664,6 +2839,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^6.0.13" }, @@ -2675,9 +2851,9 @@ } }, "node_modules/@csstools/postcss-stepped-value-functions": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-3.0.4.tgz", - "integrity": "sha512-gyNQ2YaOVXPqLR737XtReRPVu7DGKBr9JBDLoiH1T+N1ggV3r4HotRCOC1l6rxVC0zOuU1KiOzUn9Z5W838/rg==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-3.0.10.tgz", + "integrity": "sha512-MZwo0D0TYrQhT5FQzMqfy/nGZ28D1iFtpN7Su1ck5BPHS95+/Y5O9S4kEvo76f2YOsqwYcT8ZGehSI1TnzuX2g==", "funding": [ { "type": "github", @@ -2688,10 +2864,11 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^1.1.6", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-calc": "^1.2.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2701,9 +2878,9 @@ } }, "node_modules/@csstools/postcss-text-decoration-shorthand": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-3.0.4.tgz", - "integrity": "sha512-yUZmbnUemgQmja7SpOZeU45+P49wNEgQguRdyTktFkZsHf7Gof+ZIYfvF6Cm+LsU1PwSupy4yUeEKKjX5+k6cQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-3.0.7.tgz", + "integrity": "sha512-+cptcsM5r45jntU6VjotnkC9GteFR7BQBfZ5oW7inLCxj7AfLGAzMbZ60hKTP13AULVZBdxky0P8um0IBfLHVA==", "funding": [ { "type": "github", @@ -2714,8 +2891,9 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/color-helpers": "^4.0.0", + "@csstools/color-helpers": "^4.2.1", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -2726,9 +2904,9 @@ } }, "node_modules/@csstools/postcss-trigonometric-functions": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-3.0.4.tgz", - "integrity": "sha512-qj4Cxth6c38iNYzfJJWAxt8jsLrZaMVmbfGDDLOlI2YJeZoC3A5Su6/Kr7oXaPFRuspUu+4EQHngOktqVHWfVg==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-3.0.10.tgz", + "integrity": "sha512-G9G8moTc2wiad61nY5HfvxLiM/myX0aYK4s1x8MQlPH29WDPxHQM7ghGgvv2qf2xH+rrXhztOmjGHJj4jsEqXw==", "funding": [ { "type": "github", @@ -2739,10 +2917,11 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^1.1.6", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-calc": "^1.2.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" }, "engines": { "node": "^14 || ^16 || >=18" @@ -2765,6 +2944,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" }, @@ -2772,10 +2952,32 @@ "postcss": "^8.4" } }, + "node_modules/@csstools/selector-resolve-nested": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-1.1.0.tgz", + "integrity": "sha512-uWvSaeRcHyeNenKg8tp17EVDRkpflmdyvbE0DHo6D/GdBb6PDnCYYU6gRpXhtICMGMcahQmj2zGxwFM/WC8hCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, "node_modules/@csstools/selector-specificity": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.0.1.tgz", - "integrity": "sha512-NPljRHkq4a14YzZ3YD406uaxh7s0g6eAq3L9aLOWywoqe8PkYamAvtsh7KNX6c++ihDrJ0RiU+/z7rGnhlZ5ww==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz", + "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==", "funding": [ { "type": "github", @@ -2786,6 +2988,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" }, @@ -2793,11 +2996,33 @@ "postcss-selector-parser": "^6.0.13" } }, + "node_modules/@csstools/utilities": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-1.0.0.tgz", + "integrity": "sha512-tAgvZQe/t2mlvpNosA4+CkMiZ2azISW5WPAcdSalZlEjQvUfghHxfQcrCiK/7/CrfAWVxyM88kGFYO82heIGDg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, "node_modules/@cypress/request": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", - "dev": true, + "optional": true, "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -2826,7 +3051,7 @@ "version": "6.10.4", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==", - "dev": true, + "optional": true, "dependencies": { "side-channel": "^1.0.4" }, @@ -2841,7 +3066,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", - "dev": true, + "optional": true, "dependencies": { "debug": "^3.1.0", "lodash.once": "^4.1.1" @@ -2851,7 +3076,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, + "optional": true, "dependencies": { "ms": "^2.1.1" } @@ -2860,6 +3085,7 @@ "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "license": "MIT", "engines": { "node": ">=10.0.0" } @@ -2867,7 +3093,8 @@ "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT" }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -2966,52 +3193,58 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3053,6 +3286,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" @@ -3063,6 +3297,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" @@ -3075,6 +3310,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3084,6 +3320,8 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -3103,6 +3341,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3114,6 +3353,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" }, @@ -3125,6 +3365,8 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -3136,26 +3378,27 @@ } }, "node_modules/@nuxt/babel-preset-app": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/babel-preset-app/-/babel-preset-app-2.17.3.tgz", - "integrity": "sha512-KkmGEKZN2Yvo9XWC2TAJ3e3WMFQTmGGdhJy9Lv/3gW0PCUVvI5e+M+P3VF494BLKWmc4xYXaVu7cGtAUE13vMQ==", - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/core": "^7.23.7", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-imports": "^7.22.15", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/babel-preset-app/-/babel-preset-app-2.18.1.tgz", + "integrity": "sha512-7AYAGVjykrvta7k+koMGbt6y6PTMwl74PX2i9Ubyc1VC9ewy9U/b6cW0gVJOR/ZJWPzaABAgVZC7N58PprUDfA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.24.7", + "@babel/core": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-module-imports": "^7.24.7", "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/plugin-proposal-decorators": "^7.23.7", + "@babel/plugin-proposal-decorators": "^7.24.7", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", "@babel/plugin-proposal-optional-chaining": "^7.21.0", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@babel/plugin-transform-runtime": "^7.23.7", - "@babel/preset-env": "^7.23.8", - "@babel/runtime": "^7.23.8", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-env": "^7.24.7", + "@babel/runtime": "^7.24.7", "@vue/babel-preset-jsx": "^1.4.0", - "core-js": "^3.35.0", - "core-js-compat": "^3.35.0", + "core-js": "^3.37.1", + "core-js-compat": "^3.37.1", "regenerator-runtime": "^0.14.1" }, "engines": { @@ -3163,21 +3406,22 @@ } }, "node_modules/@nuxt/builder": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/builder/-/builder-2.17.3.tgz", - "integrity": "sha512-qcByuB5+Sy9xHtHT6yxsX01fT4ZMIP1bqVYqtkuwuSnbtFLc6GW2qNpzWkcxSBCzhIp2hTuulOEV6p5FpQVPLg==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/builder/-/builder-2.18.1.tgz", + "integrity": "sha512-hc4AUP3Nvov7jL0BEP7jFXt8zOfa6gt+y1kyoVvU1WHEVNcWnrGtRKvJuCwi1IwCVlx7Weh+luvHI4nzQwEeKg==", + "license": "MIT", "dependencies": { "@nuxt/devalue": "^2.0.2", - "@nuxt/utils": "2.17.3", - "@nuxt/vue-app": "2.17.3", - "@nuxt/webpack": "2.17.3", + "@nuxt/utils": "2.18.1", + "@nuxt/vue-app": "2.18.1", + "@nuxt/webpack": "2.18.1", "chalk": "^4.1.2", - "chokidar": "^3.5.3", + "chokidar": "^3.6.0", "consola": "^3.2.3", - "fs-extra": "^10.1.0", + "fs-extra": "^11.2.0", "glob": "^8.1.0", "hash-sum": "^2.0.0", - "ignore": "^5.3.0", + "ignore": "^5.3.1", "lodash": "^4.17.21", "pify": "^5.0.0", "serialize-javascript": "^6.0.2", @@ -3191,30 +3435,33 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } }, "node_modules/@nuxt/builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=14.14" } }, "node_modules/@nuxt/cli": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/cli/-/cli-2.17.3.tgz", - "integrity": "sha512-UwQbb/B/b7/N1vlorBgiW2T41Hc26+04oyze2cx1dErkDNLgjZpjcTu8cWnfSyAeRUeG2r6p8K0SPimCITNZJg==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/cli/-/cli-2.18.1.tgz", + "integrity": "sha512-ZOoDlE4Fw1Cum6oG8DVnb7B4ivovXySxdDI8vnIt49Ypx22pBGt5y2ErF7g+5TAxGMIHpyh7peJWJwYp88PqPA==", + "license": "MIT", "dependencies": { - "@nuxt/config": "2.17.3", - "@nuxt/utils": "2.17.3", + "@nuxt/config": "2.18.1", + "@nuxt/utils": "2.18.1", "boxen": "^5.1.2", "chalk": "^4.1.2", "compression": "^1.7.4", @@ -3222,17 +3469,17 @@ "consola": "^3.2.3", "crc": "^4.3.2", "defu": "^6.1.4", - "destr": "^2.0.2", + "destr": "^2.0.3", "execa": "^5.1.1", "exit": "^0.1.2", - "fs-extra": "^10.1.0", + "fs-extra": "^11.2.0", "globby": "^11.0.4", "hookable": "^4.4.1", "lodash": "^4.17.21", "minimist": "^1.2.8", "opener": "1.5.2", "pretty-bytes": "^5.6.0", - "semver": "^7.5.4", + "semver": "^7.6.2", "serve-static": "^1.15.0", "std-env": "^3.7.0", "upath": "^2.0.1", @@ -3249,6 +3496,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } @@ -3256,19 +3504,33 @@ "node_modules/@nuxt/cli/node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" }, "node_modules/@nuxt/cli/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=14.14" + } + }, + "node_modules/@nuxt/cli/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/@nuxt/components": { @@ -3329,19 +3591,20 @@ } }, "node_modules/@nuxt/config": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/config/-/config-2.17.3.tgz", - "integrity": "sha512-msHFykkeG2wPB8oP369Gha5n1O2RI57vLxSJcAnCrg6vrETfc6DadCsRA1o7v8Z9TjfVyU3leYy9c4F+elwFYg==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/config/-/config-2.18.1.tgz", + "integrity": "sha512-CTsUMFtNCJ6+7AkgMRz53zM9vxmsMYVJWBQOnikVzwFxm/jsWzjyXkp3pQb5/fNZuqR7qXmpUKIRtrdeUeN4JQ==", + "license": "MIT", "dependencies": { - "@nuxt/utils": "2.17.3", + "@nuxt/utils": "2.18.1", "consola": "^3.2.3", "defu": "^6.1.4", - "destr": "^2.0.2", - "dotenv": "^16.3.1", + "destr": "^2.0.3", + "dotenv": "^16.4.5", "lodash": "^4.17.21", - "rc9": "^2.1.1", + "rc9": "^2.1.2", "std-env": "^3.7.0", - "ufo": "^1.3.2" + "ufo": "^1.5.3" }, "engines": { "node": "^14.18.0 || >=16.10.0" @@ -3351,6 +3614,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } @@ -3358,18 +3622,20 @@ "node_modules/@nuxt/config/node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" }, "node_modules/@nuxt/core": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/core/-/core-2.17.3.tgz", - "integrity": "sha512-DAyxn49UUjmEyMImaPTjGpV7EccbbqZX14j46fWC7hNR5NkLPBMHFgYj+tsetYK5LMPcKUz1zztRoFX68SMxyw==", - "dependencies": { - "@nuxt/config": "2.17.3", - "@nuxt/server": "2.17.3", - "@nuxt/utils": "2.17.3", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/core/-/core-2.18.1.tgz", + "integrity": "sha512-BFnKVH7caEdDrK04qQ2U9F4Rf4hV/BqqXBJiIeHp7vM9CLKjTL5/yhiognDw3SBefmSJkpOATx1HJl3XM8c4fg==", + "license": "MIT", + "dependencies": { + "@nuxt/config": "2.18.1", + "@nuxt/server": "2.18.1", + "@nuxt/utils": "2.18.1", "consola": "^3.2.3", - "fs-extra": "^10.1.0", + "fs-extra": "^11.2.0", "hash-sum": "^2.0.0", "hookable": "^4.4.1", "lodash": "^4.17.21" @@ -3382,32 +3648,36 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } }, "node_modules/@nuxt/core/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=14.14" } }, "node_modules/@nuxt/devalue": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz", - "integrity": "sha512-GBzP8zOc7CGWyFQS6dv1lQz8VVpz5C2yRszbXufwG/9zhStTIH50EtD87NmWbTMwXDvZLNg8GIpb1UFdH93JCA==" + "integrity": "sha512-GBzP8zOc7CGWyFQS6dv1lQz8VVpz5C2yRszbXufwG/9zhStTIH50EtD87NmWbTMwXDvZLNg8GIpb1UFdH93JCA==", + "license": "MIT" }, "node_modules/@nuxt/friendly-errors-webpack-plugin": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@nuxt/friendly-errors-webpack-plugin/-/friendly-errors-webpack-plugin-2.6.0.tgz", "integrity": "sha512-3IZj6MXbzlvUxDncAxgBMLQwGPY/JlNhy2i+AGyOHCAReR5HcBxYjVRBvyaKM9R3s5k4OODYKeHAbrToZH/47w==", + "license": "MIT", "dependencies": { "chalk": "^2.4.2", "consola": "^3.2.3", @@ -3426,6 +3696,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -3437,6 +3708,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -3450,6 +3722,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", "dependencies": { "color-name": "1.1.3" } @@ -3457,12 +3730,14 @@ "node_modules/@nuxt/friendly-errors-webpack-plugin/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" }, "node_modules/@nuxt/friendly-errors-webpack-plugin/node_modules/consola": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } @@ -3471,6 +3746,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", "engines": { "node": ">=4" } @@ -3479,6 +3755,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -3487,19 +3764,20 @@ } }, "node_modules/@nuxt/generator": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/generator/-/generator-2.17.3.tgz", - "integrity": "sha512-m/fnzH+1RvdpDdQODUxjXlMkJzLVuOwk9AOGYZz2YaAP34nxwjxClvgIAT6IQqvq6uBZHex0Zr07N3mwEE06NA==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/generator/-/generator-2.18.1.tgz", + "integrity": "sha512-kZMfB5Ymvd/5ek+xfk2svQiMJWEAjZf5XNFTG+2WiNsitHb01Bo3W2QGidy+dwfuLtHoiOJkMovRlyAKWxTohg==", + "license": "MIT", "dependencies": { - "@nuxt/utils": "2.17.3", + "@nuxt/utils": "2.18.1", "chalk": "^4.1.2", "consola": "^3.2.3", "defu": "^6.1.4", "devalue": "^2.0.1", - "fs-extra": "^10.1.0", - "html-minifier": "^4.0.0", - "node-html-parser": "^6.1.12", - "ufo": "^1.3.2" + "fs-extra": "^11.2.0", + "html-minifier-terser": "^7.2.0", + "node-html-parser": "^6.1.13", + "ufo": "^1.5.3" }, "engines": { "node": "^14.18.0 || >=16.10.0" @@ -3509,6 +3787,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } @@ -3516,19 +3795,21 @@ "node_modules/@nuxt/generator/node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" }, "node_modules/@nuxt/generator/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=14.14" } }, "node_modules/@nuxt/loading-screen": { @@ -3574,27 +3855,28 @@ } }, "node_modules/@nuxt/server": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/server/-/server-2.17.3.tgz", - "integrity": "sha512-+HxDxni7nAHZdtBl1ptug6lHVio/aJn3o8ZkoHJjYuQ52dtJgEFsQs8EpDbKJDFYyL/u0TXEUPACrXbkOh9J8Q==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/server/-/server-2.18.1.tgz", + "integrity": "sha512-4GHmgi1NS6uCL+3QzlxmHmEoKkejQKTDrKPtA16w8iw/8EBgCrAkvXukcIMxF7Of+IYi1I/duVmCyferxo7jyw==", + "license": "MIT", "dependencies": { - "@nuxt/utils": "2.17.3", - "@nuxt/vue-renderer": "2.17.3", + "@nuxt/utils": "2.18.1", + "@nuxt/vue-renderer": "2.18.1", "@nuxtjs/youch": "^4.2.3", "compression": "^1.7.4", "connect": "^3.7.0", "consola": "^3.2.3", "etag": "^1.8.1", "fresh": "^0.5.2", - "fs-extra": "^10.1.0", - "ip": "^1.1.8", - "launch-editor-middleware": "^2.6.1", + "fs-extra": "^11.2.0", + "ip": "^2.0.1", + "launch-editor-middleware": "^2.8.0", "on-headers": "^1.0.2", "pify": "^5.0.0", - "serve-placeholder": "^2.0.1", + "serve-placeholder": "^2.0.2", "serve-static": "^1.15.0", "server-destroy": "^1.0.1", - "ufo": "^1.3.2" + "ufo": "^1.5.3" }, "engines": { "node": "^14.18.0 || >=16.10.0" @@ -3604,21 +3886,23 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } }, "node_modules/@nuxt/server/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=14.14" } }, "node_modules/@nuxt/telemetry": { @@ -3729,22 +4013,23 @@ "integrity": "sha512-Kck1Du/zQyLbq5YlBKCtrUlyyP02lYjREjKKYImtf6MZgXrLoRVjexMv0wxiDzIJPnk86i+HrvGNyI03qoewEg==" }, "node_modules/@nuxt/utils": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/utils/-/utils-2.17.3.tgz", - "integrity": "sha512-/ZdjQY+U3I6X+IiRaHX2zA9l/cgN9GD8YIYuvf2obo5u1cLHin0MNj2dwb4P2iYvygAppb8nmcEsVzG4bppoEA==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/utils/-/utils-2.18.1.tgz", + "integrity": "sha512-aWeB8VMhtymo5zXUiQaohCu8IqJqENF9iCag3wyJpdhpNDVoghGUJAl0F6mQvNTJgQzseFtf4XKqTfvcgVzyGg==", + "license": "MIT", "dependencies": { "consola": "^3.2.3", "create-require": "^1.1.1", - "fs-extra": "^10.1.0", + "fs-extra": "^11.2.0", "hash-sum": "^2.0.0", - "jiti": "^1.21.0", + "jiti": "^1.21.6", "lodash": "^4.17.21", "proper-lockfile": "^4.1.2", - "semver": "^7.5.4", + "semver": "^7.6.2", "serialize-javascript": "^6.0.2", "signal-exit": "^4.1.0", - "ua-parser-js": "^1.0.37", - "ufo": "^1.3.2" + "ua-parser-js": "^1.0.38", + "ufo": "^1.5.3" }, "engines": { "node": "^14.18.0 || >=16.10.0" @@ -3754,27 +4039,42 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } }, "node_modules/@nuxt/utils/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=14.14" + } + }, + "node_modules/@nuxt/utils/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/@nuxt/utils/node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", "engines": { "node": ">=14" }, @@ -3783,12 +4083,13 @@ } }, "node_modules/@nuxt/vue-app": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/vue-app/-/vue-app-2.17.3.tgz", - "integrity": "sha512-MgB5TKTrZwgVaccMS9YKjNerlXsjnouEfe9Eo4ChVyDybMTy6apjN6QTg+YC/J/kzrsIxrFTbYnh30dAzuZdMw==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/vue-app/-/vue-app-2.18.1.tgz", + "integrity": "sha512-yxkunoTv6EVa42xM7qES0N1DNMo4UbP/s89L7HjqngQ4KzVWyyzK0qqJ9u3Gu4CabXhHFSquu11gtn+dylKyTA==", + "license": "MIT", "dependencies": { - "node-fetch-native": "^1.6.1", - "ufo": "^1.3.2", + "node-fetch-native": "^1.6.4", + "ufo": "^1.5.3", "unfetch": "^5.0.0", "vue": "^2.7.16", "vue-client-only": "^2.1.0", @@ -3803,18 +4104,19 @@ } }, "node_modules/@nuxt/vue-renderer": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/vue-renderer/-/vue-renderer-2.17.3.tgz", - "integrity": "sha512-rSSOdta3vh47FEP8W4d+tdvJMAqejGzgQojJcruuoe+vkbo2zovFFWyISZKMFw7SCVnm0wANAwETJHpb6a3Y6Q==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/vue-renderer/-/vue-renderer-2.18.1.tgz", + "integrity": "sha512-Nl8/IbV+sTEWCczHKcjLbZrFO6y5fCcFxZwd6Opatcbr2z380abwpDf3a9UjnVW3wPEM+/xoy1/MBCLY3VmWcw==", + "license": "MIT", "dependencies": { "@nuxt/devalue": "^2.0.2", - "@nuxt/utils": "2.17.3", + "@nuxt/utils": "2.18.1", "consola": "^3.2.3", "defu": "^6.1.4", - "fs-extra": "^10.1.0", + "fs-extra": "^11.2.0", "lodash": "^4.17.21", "lru-cache": "^5.1.1", - "ufo": "^1.3.2", + "ufo": "^1.5.3", "vue": "^2.7.16", "vue-meta": "^2.4.0", "vue-server-renderer": "^2.7.16" @@ -3827,6 +4129,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } @@ -3834,36 +4137,39 @@ "node_modules/@nuxt/vue-renderer/node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" }, "node_modules/@nuxt/vue-renderer/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=14.14" } }, "node_modules/@nuxt/webpack": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/@nuxt/webpack/-/webpack-2.17.3.tgz", - "integrity": "sha512-09vP3oShjp4ogsJL3XTi2kk1gh5itG5OwerLxF1NiJNNeuIAc/kei0L3MVhyfMxUVx22SF9sb23cZLIJxoK8cQ==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nuxt/webpack/-/webpack-2.18.1.tgz", + "integrity": "sha512-6EqbIoheLAJ0E7dfQB5ftOKL4d74N98dFMY3q89QTaoS9VXBFB5D1MLd27WuyfhChmzuHRwHfjaBW8QFdhjwew==", + "license": "MIT", "dependencies": { - "@babel/core": "^7.23.7", - "@nuxt/babel-preset-app": "2.17.3", + "@babel/core": "^7.24.7", + "@nuxt/babel-preset-app": "2.18.1", "@nuxt/friendly-errors-webpack-plugin": "^2.6.0", - "@nuxt/utils": "2.17.3", + "@nuxt/utils": "2.18.1", "babel-loader": "^8.3.0", "cache-loader": "^4.1.0", - "caniuse-lite": "^1.0.30001576", + "caniuse-lite": "^1.0.30001638", "consola": "^3.2.3", "css-loader": "^5.2.7", - "cssnano": "^6.0.3", + "cssnano": "^7.0.3", "eventsource-polyfill": "^0.9.6", "extract-css-chunks-webpack-plugin": "^4.10.0", "file-loader": "^6.2.0", @@ -3872,35 +4178,36 @@ "hash-sum": "^2.0.0", "html-webpack-plugin": "^4.5.1", "lodash": "^4.17.21", - "memory-fs": "^0.5.0", + "memfs": "^4.9.3", + "mkdirp": "^0.5.6", "optimize-css-assets-webpack-plugin": "^6.0.1", "pify": "^5.0.0", "pnp-webpack-plugin": "^1.7.0", - "postcss": "^8.4.33", + "postcss": "^8.4.38", "postcss-import": "^15.1.0", "postcss-import-resolver": "^2.0.0", "postcss-loader": "^4.3.0", - "postcss-preset-env": "^9.3.0", + "postcss-preset-env": "^9.5.14", "postcss-url": "^10.1.3", - "semver": "^7.5.4", + "semver": "^7.6.2", "std-env": "^3.7.0", "style-resources-loader": "^1.5.0", "terser-webpack-plugin": "^4.2.3", "thread-loader": "^3.0.4", "time-fix-plugin": "^2.0.7", - "ufo": "^1.3.2", + "ufo": "^1.5.3", "upath": "^2.0.1", "url-loader": "^4.1.1", "vue-loader": "^15.11.1", "vue-style-loader": "^4.1.3", "vue-template-compiler": "^2.7.16", - "watchpack": "^2.4.0", + "watchpack": "^2.4.1", "webpack": "^4.47.0", - "webpack-bundle-analyzer": "^4.10.1", - "webpack-dev-middleware": "^5.0.0", - "webpack-hot-middleware": "^2.26.0", + "webpack-bundle-analyzer": "^4.10.2", + "webpack-dev-middleware": "^5.3.4", + "webpack-hot-middleware": "^2.26.1", "webpack-node-externals": "^3.0.0", - "webpackbar": "^6.0.0" + "webpackbar": "^6.0.1" }, "engines": { "node": "^14.18.0 || >=16.10.0" @@ -3910,24 +4217,21 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/@nuxt/webpack/node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" + "node_modules/@nuxt/webpack/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" + "node": ">=10" } }, "node_modules/@nuxtjs/axios": { @@ -3976,6 +4280,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/@nuxtjs/youch/-/youch-4.2.3.tgz", "integrity": "sha512-XiTWdadTwtmL/IGkNqbVe+dOlT+IMvcBu7TvKI7plWhVQeBCQ9iKhk3jgvVWFyiwL2yHJDlEwOM5v9oVES5Xmw==", + "license": "MIT", "dependencies": { "cookie": "^0.3.1", "mustache": "^2.3.0", @@ -3993,9 +4298,10 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.24", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", - "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==" + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", + "license": "MIT" }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", @@ -4046,6 +4352,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "license": "ISC", "engines": { "node": ">=10.13.0" } @@ -4185,7 +4492,8 @@ "node_modules/@types/html-minifier-terser": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz", - "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==" + "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==", + "license": "MIT" }, "node_modules/@types/http-errors": { "version": "2.0.4", @@ -4243,7 +4551,8 @@ "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" }, "node_modules/@types/pug": { "version": "2.0.10", @@ -4288,13 +4597,13 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", - "dev": true + "optional": true }, "node_modules/@types/sizzle": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", - "dev": true + "optional": true }, "node_modules/@types/source-list-map": { "version": "0.1.6", @@ -4375,7 +4684,6 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, "optional": true, "dependencies": { "@types/node": "*" @@ -4384,12 +4692,14 @@ "node_modules/@vue/babel-helper-vue-jsx-merge-props": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.4.0.tgz", - "integrity": "sha512-JkqXfCkUDp4PIlFdDQ0TdXoIejMtTHP67/pvxlgeY+u5k3LEdKuWZ3LK6xkxo52uDoABIVyRwqVkfLQJhk7VBA==" + "integrity": "sha512-JkqXfCkUDp4PIlFdDQ0TdXoIejMtTHP67/pvxlgeY+u5k3LEdKuWZ3LK6xkxo52uDoABIVyRwqVkfLQJhk7VBA==", + "license": "MIT" }, "node_modules/@vue/babel-plugin-transform-vue-jsx": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.4.0.tgz", "integrity": "sha512-Fmastxw4MMx0vlgLS4XBX0XiBbUFzoMGeVXuMV08wyOfXdikAFqBTuYPR0tlk+XskL19EzHc39SgjrPGY23JnA==", + "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.0.0", "@babel/plugin-syntax-jsx": "^7.2.0", @@ -4406,6 +4716,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-preset-jsx/-/babel-preset-jsx-1.4.0.tgz", "integrity": "sha512-QmfRpssBOPZWL5xw7fOuHNifCQcNQC1PrOo/4fu6xlhlKJJKSA3HqX92Nvgyx8fqHZTUGMPHmFA+IDqwXlqkSA==", + "license": "MIT", "dependencies": { "@vue/babel-helper-vue-jsx-merge-props": "^1.4.0", "@vue/babel-plugin-transform-vue-jsx": "^1.4.0", @@ -4430,6 +4741,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-sugar-composition-api-inject-h/-/babel-sugar-composition-api-inject-h-1.4.0.tgz", "integrity": "sha512-VQq6zEddJHctnG4w3TfmlVp5FzDavUSut/DwR0xVoe/mJKXyMcsIibL42wPntozITEoY90aBV0/1d2KjxHU52g==", + "license": "MIT", "dependencies": { "@babel/plugin-syntax-jsx": "^7.2.0" }, @@ -4441,6 +4753,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-sugar-composition-api-render-instance/-/babel-sugar-composition-api-render-instance-1.4.0.tgz", "integrity": "sha512-6ZDAzcxvy7VcnCjNdHJ59mwK02ZFuP5CnucloidqlZwVQv5CQLijc3lGpR7MD3TWFi78J7+a8J56YxbCtHgT9Q==", + "license": "MIT", "dependencies": { "@babel/plugin-syntax-jsx": "^7.2.0" }, @@ -4452,6 +4765,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-sugar-functional-vue/-/babel-sugar-functional-vue-1.4.0.tgz", "integrity": "sha512-lTEB4WUFNzYt2In6JsoF9sAYVTo84wC4e+PoZWSgM6FUtqRJz7wMylaEhSRgG71YF+wfLD6cc9nqVeXN2rwBvw==", + "license": "MIT", "dependencies": { "@babel/plugin-syntax-jsx": "^7.2.0" }, @@ -4463,6 +4777,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-sugar-inject-h/-/babel-sugar-inject-h-1.4.0.tgz", "integrity": "sha512-muwWrPKli77uO2fFM7eA3G1lAGnERuSz2NgAxuOLzrsTlQl8W4G+wwbM4nB6iewlKbwKRae3nL03UaF5ffAPMA==", + "license": "MIT", "dependencies": { "@babel/plugin-syntax-jsx": "^7.2.0" }, @@ -4474,6 +4789,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-sugar-v-model/-/babel-sugar-v-model-1.4.0.tgz", "integrity": "sha512-0t4HGgXb7WHYLBciZzN5s0Hzqan4Ue+p/3FdQdcaHAb7s5D9WZFGoSxEZHrR1TFVZlAPu1bejTKGeAzaaG3NCQ==", + "license": "MIT", "dependencies": { "@babel/plugin-syntax-jsx": "^7.2.0", "@vue/babel-helper-vue-jsx-merge-props": "^1.4.0", @@ -4490,6 +4806,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-sugar-v-on/-/babel-sugar-v-on-1.4.0.tgz", "integrity": "sha512-m+zud4wKLzSKgQrWwhqRObWzmTuyzl6vOP7024lrpeJM4x2UhQtRDLgYjXAw9xBXjCwS0pP9kXjg91F9ZNo9JA==", + "license": "MIT", "dependencies": { "@babel/plugin-syntax-jsx": "^7.2.0", "@vue/babel-plugin-transform-vue-jsx": "^1.4.0", @@ -4516,6 +4833,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@vue/component-compiler-utils/-/component-compiler-utils-3.3.0.tgz", "integrity": "sha512-97sfH2mYNU+2PzGrmK2haqffDpVASuib9/w2/noxiFi31Z54hW+q3izKQXXQZSNhtiUpAI36uSuYepeBe4wpHQ==", + "license": "MIT", "dependencies": { "consolidate": "^0.15.1", "hash-sum": "^1.0.2", @@ -4533,12 +4851,14 @@ "node_modules/@vue/component-compiler-utils/node_modules/hash-sum": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz", - "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==" + "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==", + "license": "MIT" }, "node_modules/@vue/component-compiler-utils/node_modules/lru-cache": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "license": "ISC", "dependencies": { "pseudomap": "^1.0.2", "yallist": "^2.1.2" @@ -4547,12 +4867,14 @@ "node_modules/@vue/component-compiler-utils/node_modules/picocolors": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", - "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==" + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "license": "ISC" }, "node_modules/@vue/component-compiler-utils/node_modules/postcss": { "version": "7.0.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "license": "MIT", "dependencies": { "picocolors": "^0.2.1", "source-map": "^0.6.1" @@ -4568,7 +4890,8 @@ "node_modules/@vue/component-compiler-utils/node_modules/yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "license": "ISC" }, "node_modules/@webassemblyjs/ast": { "version": "1.9.0", @@ -4769,9 +5092,25 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", + "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk/node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, "engines": { "node": ">=0.4.0" } @@ -4815,6 +5154,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", "dependencies": { "ajv": "^8.0.0" }, @@ -4828,14 +5168,15 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -4845,7 +5186,8 @@ "node_modules/ajv-formats/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/ajv-keywords": { "version": "3.5.2", @@ -4859,6 +5201,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", "dependencies": { "string-width": "^4.1.0" } @@ -4867,7 +5210,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, + "optional": true, "engines": { "node": ">=6" } @@ -4904,6 +5247,7 @@ "engines": [ "node >= 0.8.0" ], + "license": "Apache-2.0", "bin": { "ansi-html": "bin/ansi-html" } @@ -4957,7 +5301,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "dev": true, "funding": [ { "type": "github", @@ -4971,7 +5314,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "optional": true }, "node_modules/arg": { "version": "5.0.2", @@ -5003,12 +5347,16 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5031,14 +5379,17 @@ } }, "node_modules/array.prototype.reduce": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.6.tgz", - "integrity": "sha512-UW+Mz8LG/sPSU8jRDCjVr6J/ZKAGpHfwrZ6kWTG5qCxIEiXdVshqGnu5vEZA8S1y6X4aCSbQZ0/EEsfvEvBiSg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz", + "integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", "es-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", "is-string": "^1.0.7" }, "engines": { @@ -5049,16 +5400,18 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", - "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -5072,7 +5425,7 @@ "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dev": true, + "optional": true, "dependencies": { "safer-buffer": "~2.1.0" } @@ -5106,7 +5459,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, + "optional": true, "engines": { "node": ">=0.8" } @@ -5136,7 +5489,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, + "optional": true, "engines": { "node": ">=8" } @@ -5145,7 +5498,7 @@ "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "dev": true + "optional": true }, "node_modules/async-each": { "version": "1.0.6", @@ -5163,13 +5516,13 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "optional": true }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, + "devOptional": true, "engines": { "node": ">= 4.0.0" } @@ -5186,9 +5539,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "funding": [ { "type": "opencollective", @@ -5203,12 +5556,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -5222,9 +5576,13 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -5236,7 +5594,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "dev": true, + "optional": true, "engines": { "node": "*" } @@ -5245,7 +5603,7 @@ "version": "1.12.0", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", - "dev": true + "optional": true }, "node_modules/axios": { "version": "0.21.4", @@ -5268,6 +5626,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "license": "MIT", "dependencies": { "find-cache-dir": "^3.3.1", "loader-utils": "^2.0.0", @@ -5286,6 +5645,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -5297,6 +5657,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -5310,6 +5671,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.5", "ajv": "^6.12.4", @@ -5324,12 +5686,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", - "integrity": "sha512-OtIuQfafSzpo/LhnJaykc0R/MMnuLSSVjVYy9mHArIZ9qTCSZ6TpWCuEKZYVoN//t8HqBNScHrOtCrIK5IaGLg==", + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", + "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", + "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.5.0", + "@babel/helper-define-polyfill-provider": "^0.6.2", "semver": "^6.3.1" }, "peerDependencies": { @@ -5340,43 +5703,31 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", - "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4", - "core-js-compat": "^3.33.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", - "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", - "integrity": "sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", + "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", + "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.5.0" + "@babel/helper-define-polyfill-provider": "^0.6.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -5451,7 +5802,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dev": true, + "optional": true, "dependencies": { "tweetnacl": "^0.14.3" } @@ -5485,7 +5836,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", - "dev": true + "optional": true }, "node_modules/bluebird": { "version": "3.7.2", @@ -5500,12 +5851,14 @@ "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" }, "node_modules/boxen": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "license": "MIT", "dependencies": { "ansi-align": "^3.0.0", "camelcase": "^6.2.0", @@ -5527,6 +5880,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -5538,6 +5892,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -5672,9 +6027,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "funding": [ { "type": "opencollective", @@ -5689,11 +6044,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -5702,36 +6058,11 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "optional": true, - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, + "optional": true, "engines": { "node": "*" } @@ -5744,7 +6075,8 @@ "node_modules/buffer-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/buffer-json/-/buffer-json-2.0.0.tgz", - "integrity": "sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==" + "integrity": "sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==", + "license": "MIT" }, "node_modules/buffer-xor": { "version": "1.0.3", @@ -5760,6 +6092,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5768,6 +6101,7 @@ "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", @@ -5796,6 +6130,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5805,6 +6140,8 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -5824,6 +6161,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -5835,6 +6173,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5846,6 +6185,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" }, @@ -5857,6 +6197,8 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -5870,7 +6212,8 @@ "node_modules/cacache/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, "node_modules/cache-base": { "version": "1.0.1", @@ -5895,6 +6238,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cache-loader/-/cache-loader-4.1.0.tgz", "integrity": "sha512-ftOayxve0PwKzBF/GLsZNC9fJBXl8lkZE3TOsjkboHfVHVkL39iUEs1FO07A33mizmci5Dudt38UZrrYXDtbhw==", + "license": "MIT", "dependencies": { "buffer-json": "^2.0.0", "find-cache-dir": "^3.0.0", @@ -5914,6 +6258,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.5", "ajv": "^6.12.4", @@ -5931,19 +6276,25 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", - "dev": true, + "optional": true, "engines": { "node": ">=6" } }, "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5953,23 +6304,32 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/camel-case": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz", - "integrity": "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "license": "MIT", "dependencies": { - "no-case": "^2.2.0", - "upper-case": "^1.1.1" + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" } }, + "node_modules/camel-case/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", "engines": { "node": ">=6" } @@ -5987,6 +6347,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "license": "MIT", "dependencies": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", @@ -5995,9 +6356,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001579", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", - "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "version": "1.0.30001655", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz", + "integrity": "sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==", "funding": [ { "type": "opencollective", @@ -6011,13 +6372,14 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true + "optional": true }, "node_modules/chalk": { "version": "4.1.2", @@ -6043,21 +6405,16 @@ "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", - "dev": true, + "optional": true, "engines": { "node": ">= 0.8.0" } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6070,6 +6427,9 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } @@ -6078,6 +6438,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", "engines": { "node": ">=10" } @@ -6151,14 +6512,15 @@ } }, "node_modules/clean-css": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", - "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "license": "MIT", "dependencies": { "source-map": "~0.6.0" }, "engines": { - "node": ">= 4.0" + "node": ">= 10.0" } }, "node_modules/clean-stack": { @@ -6173,6 +6535,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "license": "MIT", "engines": { "node": ">=6" }, @@ -6195,7 +6558,7 @@ "version": "0.6.4", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.4.tgz", "integrity": "sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw==", - "dev": true, + "optional": true, "dependencies": { "string-width": "^4.2.0" }, @@ -6210,7 +6573,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, + "optional": true, "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" @@ -6275,7 +6638,8 @@ "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "license": "MIT" }, "node_modules/colorette": { "version": "2.0.20", @@ -6286,7 +6650,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "optional": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -6303,7 +6667,7 @@ "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "dev": true, + "optional": true, "engines": { "node": ">=4.0.0" } @@ -6325,6 +6689,7 @@ "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", "dependencies": { "mime-db": ">= 1.43.0 < 2" }, @@ -6336,6 +6701,7 @@ "version": "1.7.4", "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "license": "MIT", "dependencies": { "accepts": "~1.3.5", "bytes": "3.0.0", @@ -6353,6 +6719,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -6360,7 +6727,8 @@ "node_modules/compression/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", @@ -6423,6 +6791,7 @@ "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.15.1.tgz", "integrity": "sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw==", "deprecated": "Please upgrade to consolidate v1.0.0+ as it has been modernized with several long-awaited fixes implemented. Maintenance is supported by Forward Email at https://forwardemail.net ; follow/watch https://github.com/ladjs/consolidate for updates and release changelog", + "license": "MIT", "dependencies": { "bluebird": "^3.1.1" }, @@ -6438,12 +6807,14 @@ "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" }, "node_modules/cookie": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", "integrity": "sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6470,21 +6841,23 @@ } }, "node_modules/core-js": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.0.tgz", - "integrity": "sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==", + "version": "3.38.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz", + "integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==", "hasInstallScript": true, + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" } }, "node_modules/core-js-compat": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.0.tgz", - "integrity": "sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==", + "version": "3.38.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz", + "integrity": "sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==", + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2" + "browserslist": "^4.23.3" }, "funding": { "type": "opencollective", @@ -6512,6 +6885,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -6527,6 +6901,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -6544,6 +6919,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/crc/-/crc-4.3.2.tgz", "integrity": "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A==", + "license": "MIT", "engines": { "node": ">=12" }, @@ -6646,9 +7022,9 @@ } }, "node_modules/css-blank-pseudo": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-6.0.1.tgz", - "integrity": "sha512-goSnEITByxTzU4Oh5oJZrEWudxTqk7L6IXj1UW69pO6Hv0UdX+Vsrt02FFu5DweRh2bLu6WpX/+zsQCu5O1gKw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-6.0.2.tgz", + "integrity": "sha512-J/6m+lsqpKPqWHOifAFtKFeGLOzw3jR92rxQcwRUfA/eTuZzKfKlxOmYDx2+tqOPQAueNvBiY8WhAeHu5qNmTg==", "funding": [ { "type": "github", @@ -6659,6 +7035,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^6.0.13" }, @@ -6670,9 +7047,10 @@ } }, "node_modules/css-declaration-sorter": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.1.1.tgz", - "integrity": "sha512-dZ3bVTEEc1vxr3Bek9vGwfB5Z6ESPULhcRvO472mfjVnj8jRcTnKO8/JTczlvxM10Myb+wBM++1MtdO76eWcaQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz", + "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==", + "license": "ISC", "engines": { "node": "^14 || ^16 || >=18" }, @@ -6681,9 +7059,9 @@ } }, "node_modules/css-has-pseudo": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-6.0.1.tgz", - "integrity": "sha512-WwoVKqNxApfEI7dWFyaHoeFCcUPD+lPyjL6lNpRUNX7IyIUuVpawOTwwA5D0ZR6V2xQZonNPVj8kEcxzEaAQfQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-6.0.5.tgz", + "integrity": "sha512-ZTv6RlvJJZKp32jPYnAJVhowDCrRrHUTAxsYSuUPBEDJjzws6neMnzkRblxtgmv1RgcV5dhH2gn7E3wA9Wt6lw==", "funding": [ { "type": "github", @@ -6694,8 +7072,9 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/selector-specificity": "^3.0.1", + "@csstools/selector-specificity": "^3.1.1", "postcss-selector-parser": "^6.0.13", "postcss-value-parser": "^4.2.0" }, @@ -6710,6 +7089,7 @@ "version": "5.2.7", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", "loader-utils": "^2.0.0", @@ -6737,6 +7117,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -6748,6 +7129,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -6771,6 +7153,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" }, @@ -6782,6 +7165,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -6797,6 +7181,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" @@ -6809,6 +7194,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", "engines": { "node": ">= 6" }, @@ -6817,9 +7203,9 @@ } }, "node_modules/cssdb": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.10.0.tgz", - "integrity": "sha512-yGZ5tmA57gWh/uvdQBHs45wwFY0IBh3ypABk5sEubPBPSzXzkNgsWReqx7gdx6uhC+QoFBe+V8JwBB9/hQ6cIA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.1.0.tgz", + "integrity": "sha512-BQN57lfS4dYt2iL0LgyrlDbefZKEtUyrO8rbzrbGrqBk6OoyNTQLF+porY9DrpDBjLo4NEvj2IJttC7vf3x+Ew==", "funding": [ { "type": "opencollective", @@ -6829,7 +7215,8 @@ "type": "github", "url": "https://github.com/sponsors/csstools" } - ] + ], + "license": "MIT-0" }, "node_modules/cssesc": { "version": "3.0.0", @@ -6843,15 +7230,16 @@ } }, "node_modules/cssnano": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.3.tgz", - "integrity": "sha512-MRq4CIj8pnyZpcI2qs6wswoYoDD1t0aL28n+41c1Ukcpm56m1h6mCexIHBGjfZfnTqtGSSCP4/fB1ovxgjBOiw==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.0.5.tgz", + "integrity": "sha512-Aq0vqBLtpTT5Yxj+hLlLfNPFuRQCDIjx5JQAhhaedQKLNDvDGeVziF24PS+S1f0Z5KCxWvw0QVI3VNHNBITxVQ==", + "license": "MIT", "dependencies": { - "cssnano-preset-default": "^6.0.3", - "lilconfig": "^3.0.0" + "cssnano-preset-default": "^7.0.5", + "lilconfig": "^3.1.2" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "funding": { "type": "opencollective", @@ -6862,53 +7250,56 @@ } }, "node_modules/cssnano-preset-default": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.0.3.tgz", - "integrity": "sha512-4y3H370aZCkT9Ev8P4SO4bZbt+AExeKhh8wTbms/X7OLDo5E7AYUUy6YPxa/uF5Grf+AJwNcCnxKhZynJ6luBA==", - "dependencies": { - "css-declaration-sorter": "^7.1.1", - "cssnano-utils": "^4.0.1", - "postcss-calc": "^9.0.1", - "postcss-colormin": "^6.0.2", - "postcss-convert-values": "^6.0.2", - "postcss-discard-comments": "^6.0.1", - "postcss-discard-duplicates": "^6.0.1", - "postcss-discard-empty": "^6.0.1", - "postcss-discard-overridden": "^6.0.1", - "postcss-merge-longhand": "^6.0.2", - "postcss-merge-rules": "^6.0.3", - "postcss-minify-font-values": "^6.0.1", - "postcss-minify-gradients": "^6.0.1", - "postcss-minify-params": "^6.0.2", - "postcss-minify-selectors": "^6.0.2", - "postcss-normalize-charset": "^6.0.1", - "postcss-normalize-display-values": "^6.0.1", - "postcss-normalize-positions": "^6.0.1", - "postcss-normalize-repeat-style": "^6.0.1", - "postcss-normalize-string": "^6.0.1", - "postcss-normalize-timing-functions": "^6.0.1", - "postcss-normalize-unicode": "^6.0.2", - "postcss-normalize-url": "^6.0.1", - "postcss-normalize-whitespace": "^6.0.1", - "postcss-ordered-values": "^6.0.1", - "postcss-reduce-initial": "^6.0.2", - "postcss-reduce-transforms": "^6.0.1", - "postcss-svgo": "^6.0.2", - "postcss-unique-selectors": "^6.0.2" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.5.tgz", + "integrity": "sha512-Jbzja0xaKwc5JzxPQoc+fotKpYtWEu4wQLMQe29CM0FjjdRjA4omvbGHl2DTGgARKxSTpPssBsok+ixv8uTBqw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^5.0.0", + "postcss-calc": "^10.0.1", + "postcss-colormin": "^7.0.2", + "postcss-convert-values": "^7.0.3", + "postcss-discard-comments": "^7.0.2", + "postcss-discard-duplicates": "^7.0.1", + "postcss-discard-empty": "^7.0.0", + "postcss-discard-overridden": "^7.0.0", + "postcss-merge-longhand": "^7.0.3", + "postcss-merge-rules": "^7.0.3", + "postcss-minify-font-values": "^7.0.0", + "postcss-minify-gradients": "^7.0.0", + "postcss-minify-params": "^7.0.2", + "postcss-minify-selectors": "^7.0.3", + "postcss-normalize-charset": "^7.0.0", + "postcss-normalize-display-values": "^7.0.0", + "postcss-normalize-positions": "^7.0.0", + "postcss-normalize-repeat-style": "^7.0.0", + "postcss-normalize-string": "^7.0.0", + "postcss-normalize-timing-functions": "^7.0.0", + "postcss-normalize-unicode": "^7.0.2", + "postcss-normalize-url": "^7.0.0", + "postcss-normalize-whitespace": "^7.0.0", + "postcss-ordered-values": "^7.0.1", + "postcss-reduce-initial": "^7.0.2", + "postcss-reduce-transforms": "^7.0.0", + "postcss-svgo": "^7.0.1", + "postcss-unique-selectors": "^7.0.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/cssnano-utils": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.1.tgz", - "integrity": "sha512-6qQuYDqsGoiXssZ3zct6dcMxiqfT6epy7x4R0TQJadd4LWO3sPR6JH6ZByOvVLoZ6EdwPGgd7+DR1EmX3tiXQQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.0.tgz", + "integrity": "sha512-Uij0Xdxc24L6SirFr25MlwC2rCFX6scyUmuKpzI+JQ7cyqDEwD42fJ0xfB3yLfOnRDU5LKGgjQ9FA6LYh76GWQ==", + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" @@ -6918,6 +7309,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", "dependencies": { "css-tree": "~2.2.0" }, @@ -6930,6 +7322,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" @@ -6942,7 +7335,8 @@ "node_modules/csso/node_modules/mdn-data": { "version": "2.0.28", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==" + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" }, "node_modules/csstype": { "version": "3.1.3", @@ -6952,7 +7346,8 @@ "node_modules/cuint": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", - "integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==" + "integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==", + "license": "MIT" }, "node_modules/cyclist": { "version": "1.0.2", @@ -6963,8 +7358,8 @@ "version": "13.7.3", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.7.3.tgz", "integrity": "sha512-uoecY6FTCAuIEqLUYkTrxamDBjMHTYak/1O7jtgwboHiTnS1NaMOoR08KcTrbRZFCBvYOiS4tEkQRmsV+xcrag==", - "dev": true, "hasInstallScript": true, + "optional": true, "dependencies": { "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", @@ -7020,7 +7415,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -7035,6 +7429,7 @@ "url": "https://feross.org/support" } ], + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -7044,7 +7439,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, + "optional": true, "engines": { "node": ">= 6" } @@ -7053,7 +7448,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, + "optional": true, "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", @@ -7076,7 +7471,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, + "optional": true, "dependencies": { "pump": "^3.0.0" }, @@ -7091,7 +7486,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, + "optional": true, "engines": { "node": ">=8.12.0" } @@ -7100,7 +7495,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, + "optional": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7115,7 +7510,7 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "dev": true, + "optional": true, "engines": { "node": ">=14.14" } @@ -7133,7 +7528,7 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dev": true, + "optional": true, "dependencies": { "assert-plus": "^1.0.0" }, @@ -7141,6 +7536,57 @@ "node": ">=0.10" } }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -7160,7 +7606,7 @@ "version": "1.11.10", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==", - "dev": true + "optional": true }, "node_modules/de-indent": { "version": "1.0.2", @@ -7170,7 +7616,8 @@ "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" }, "node_modules/debug": { "version": "4.3.4", @@ -7200,21 +7647,26 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-properties": { @@ -7255,7 +7707,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, + "optional": true, "engines": { "node": ">=0.4.0" } @@ -7278,9 +7730,10 @@ } }, "node_modules/destr": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.2.tgz", - "integrity": "sha512-65AlobnZMiCET00KaFFjUefxDX0khFA/E4myqZ7a6Sq1yZtR8+FVIvilVX66vF2uobSumxooYZChiRPCKNqhmg==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz", + "integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==", + "license": "MIT" }, "node_modules/destroy": { "version": "1.2.0", @@ -7295,6 +7748,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz", "integrity": "sha512-rlpvsxUtM0PQvy9iZe640/IWwWYyBsTApREbA1pHOpmOUIl9MkP/U4z7vTtg4Oaojvqhxt7sdufnT0EzGaR31g==", + "license": "MIT", "engines": { "node": ">=4" } @@ -7302,7 +7756,8 @@ "node_modules/devalue": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-2.0.1.tgz", - "integrity": "sha512-I2TiqT5iWBEyB8GRfTDP0hiLZ0YeDJZ+upDxjBfOC2lebO5LezQMv7QvIUTzdb64jQyAKLf1AHADtGN+jw6v8Q==" + "integrity": "sha512-I2TiqT5iWBEyB8GRfTDP0hiLZ0YeDJZ+upDxjBfOC2lebO5LezQMv7QvIUTzdb64jQyAKLf1AHADtGN+jw6v8Q==", + "license": "MIT" }, "node_modules/didyoumean": { "version": "1.2.2", @@ -7346,6 +7801,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "license": "MIT", "dependencies": { "utila": "~0.4" } @@ -7354,6 +7810,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -7381,12 +7838,14 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -7401,6 +7860,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -7414,48 +7874,35 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, - "node_modules/dot-case/node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/dot-case/node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, "node_modules/dot-case/node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" }, "node_modules/dotenv": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.2.tgz", - "integrity": "sha512-HTlk5nmhkm8F6JcdXvHIzaorzCoziNQT9mGxLPVXW8wJF1TiGSL60ZGB4gHWabHOaMmWmhvk2/lPHfnBiT78AQ==", + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" + "url": "https://dotenvx.com" } }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" }, "node_modules/duplexify": { "version": "3.7.1", @@ -7478,7 +7925,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dev": true, + "optional": true, "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -7490,9 +7937,10 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.639", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.639.tgz", - "integrity": "sha512-CkKf3ZUVZchr+zDpAlNLEEy2NJJ9T64ULWaDgy3THXXlPVPkLu3VOs9Bac44nebVtdwl2geSj6AxTtGDOxoXhg==" + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", + "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", + "license": "ISC" }, "node_modules/elliptic": { "version": "6.5.4", @@ -7607,7 +8055,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, + "optional": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -7620,6 +8068,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -7658,6 +8107,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } @@ -7666,54 +8116,63 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", "dependencies": { "stackframe": "^1.3.4" } }, "node_modules/es-abstract": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", - "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.2", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.5", - "es-set-tostringtag": "^2.0.1", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", "es-to-primitive": "^1.2.1", "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.2", - "get-symbol-description": "^1.0.0", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "hasown": "^2.0.0", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", + "is-shared-array-buffer": "^1.0.3", "is-string": "^1.0.7", - "is-typed-array": "^1.1.12", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "safe-array-concat": "^1.0.1", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.8", - "string.prototype.trimend": "^1.0.7", - "string.prototype.trimstart": "^1.0.7", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.13" + "which-typed-array": "^1.1.15" }, "engines": { "node": ">= 0.4" @@ -7725,16 +8184,51 @@ "node_modules/es-array-method-boxes-properly": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } }, "node_modules/es-set-tostringtag": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", - "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2", - "has-tostringtag": "^1.0.0", - "hasown": "^2.0.0" + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -7744,6 +8238,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "license": "MIT", "dependencies": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -7790,9 +8285,10 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -7853,6 +8349,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -7878,7 +8375,7 @@ "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", - "dev": true + "optional": true }, "node_modules/eventemitter3": { "version": "4.0.7", @@ -7896,7 +8393,8 @@ "node_modules/eventsource-polyfill": { "version": "0.9.6", "resolved": "https://registry.npmjs.org/eventsource-polyfill/-/eventsource-polyfill-0.9.6.tgz", - "integrity": "sha512-LyMFp2oPDGhum2lMvkjqKZEwWd2/AoXyt8aoyftTBMWwPHNgU+2tdxhTHPluDxoz+z4gNj0uHAPR9nqevATMbg==" + "integrity": "sha512-LyMFp2oPDGhum2lMvkjqKZEwWd2/AoXyt8aoyftTBMWwPHNgU+2tdxhTHPluDxoz+z4gNj0uHAPR9nqevATMbg==", + "license": "MIT" }, "node_modules/evp_bytestokey": { "version": "1.0.3", @@ -7933,7 +8431,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", - "dev": true, + "optional": true, "dependencies": { "pify": "^2.2.0" }, @@ -7945,7 +8443,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, + "optional": true, "engines": { "node": ">=0.10.0" } @@ -8047,7 +8545,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "optional": true }, "node_modules/extend-shallow": { "version": "3.0.2", @@ -8126,6 +8624,7 @@ "version": "4.10.0", "resolved": "https://registry.npmjs.org/extract-css-chunks-webpack-plugin/-/extract-css-chunks-webpack-plugin-4.10.0.tgz", "integrity": "sha512-D/wb/Tbexq8XMBl4uhthto25WBaHI9P8vucDdzwPtLTyVi4Rdw/aiRLSL2rHaF6jZfPAjThWXepFU9PXsdtIbA==", + "license": "MIT", "dependencies": { "loader-utils": "^2.0.4", "normalize-url": "1.9.1", @@ -8143,6 +8642,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -8154,6 +8654,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -8167,6 +8668,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "license": "MIT", "dependencies": { "ajv": "^6.1.0", "ajv-errors": "^1.0.0", @@ -8180,7 +8682,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, + "optional": true, "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", @@ -8200,7 +8702,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, + "optional": true, "dependencies": { "pump": "^3.0.0" }, @@ -8215,10 +8717,10 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "dev": true, "engines": [ "node >=0.6.0" - ] + ], + "optional": true }, "node_modules/fast-average-color": { "version": "9.4.0", @@ -8253,6 +8755,12 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, + "node_modules/fast-uri": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==", + "license": "MIT" + }, "node_modules/fastq": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", @@ -8265,7 +8773,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, + "optional": true, "dependencies": { "pend": "~1.2.0" } @@ -8294,6 +8802,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "license": "MIT", "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" @@ -8313,6 +8822,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -8324,6 +8834,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -8384,6 +8895,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "license": "MIT", "dependencies": { "commondir": "^1.0.1", "make-dir": "^3.0.2", @@ -8400,6 +8912,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -8408,14 +8921,6 @@ "node": ">=8" } }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "bin": { - "flat": "cli.js" - } - }, "node_modules/flush-write-stream": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", @@ -8448,6 +8953,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "license": "MIT", "dependencies": { "is-callable": "^1.1.3" } @@ -8492,7 +8998,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "dev": true, + "optional": true, "engines": { "node": "*" } @@ -8501,7 +9007,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, + "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -8555,7 +9061,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, + "devOptional": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -8575,6 +9081,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -8583,9 +9090,10 @@ } }, "node_modules/fs-monkey": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", - "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==" + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", + "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", + "license": "Unlicense" }, "node_modules/fs-write-stream-atomic": { "version": "1.0.10", @@ -8628,6 +9136,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -8645,6 +9154,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8653,20 +9163,26 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8691,12 +9207,14 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" @@ -8717,7 +9235,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, + "optional": true, "dependencies": { "async": "^3.2.0" } @@ -8726,7 +9244,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dev": true, + "optional": true, "dependencies": { "assert-plus": "^1.0.0" } @@ -8760,6 +9278,8 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -8788,13 +9308,14 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "dev": true, + "optional": true, "dependencies": { "ini": "2.0.0" }, @@ -8809,7 +9330,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, + "optional": true, "engines": { "node": ">=10" } @@ -8818,16 +9339,19 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -8875,6 +9399,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "license": "MIT", "dependencies": { "duplexer": "^0.1.2" }, @@ -8889,6 +9414,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/hard-source-webpack-plugin/-/hard-source-webpack-plugin-0.13.1.tgz", "integrity": "sha512-r9zf5Wq7IqJHdVAQsZ4OP+dcUSvoHqDMxJlIzaE2J0TZWn3UjMMrHqwDHR8Jr/pzPfG7XxSe36E7Y8QGNdtuAw==", + "license": "ISC", "dependencies": { "chalk": "^2.4.1", "find-cache-dir": "^2.0.0", @@ -8915,6 +9441,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -8926,6 +9453,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -8939,6 +9467,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", "dependencies": { "color-name": "1.1.3" } @@ -8946,12 +9475,14 @@ "node_modules/hard-source-webpack-plugin/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" }, "node_modules/hard-source-webpack-plugin/node_modules/find-cache-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "license": "MIT", "dependencies": { "commondir": "^1.0.1", "make-dir": "^2.0.0", @@ -8965,6 +9496,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "license": "MIT", "dependencies": { "locate-path": "^3.0.0" }, @@ -8976,6 +9508,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", "engines": { "node": ">=4" } @@ -8984,6 +9517,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "license": "MIT", "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" @@ -8996,6 +9530,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "license": "MIT", "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -9008,6 +9543,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "license": "MIT", "dependencies": { "p-limit": "^2.0.0" }, @@ -9019,6 +9555,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "license": "MIT", "engines": { "node": ">=4" } @@ -9027,6 +9564,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "license": "MIT", "engines": { "node": ">=6" } @@ -9035,6 +9573,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "license": "MIT", "dependencies": { "find-up": "^3.0.0" }, @@ -9046,6 +9585,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", "bin": { "semver": "bin/semver" } @@ -9054,6 +9594,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -9065,6 +9606,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9078,20 +9620,22 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -9111,11 +9655,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -9230,7 +9775,8 @@ "node_modules/hash-sum": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz", - "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==" + "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==", + "license": "MIT" }, "node_modules/hash.js": { "version": "1.1.7", @@ -9258,9 +9804,10 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -9294,12 +9841,13 @@ "node_modules/hookable": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/hookable/-/hookable-4.4.1.tgz", - "integrity": "sha512-KWjZM8C7IVT2qne5HTXjM6R6VnRfjfRlf/oCnHd+yFxoHO1DzOl6B9LzV/VqGQK/IrFewq+EG+ePVrE9Tpc3fg==" + "integrity": "sha512-KWjZM8C7IVT2qne5HTXjM6R6VnRfjfRlf/oCnHd+yFxoHO1DzOl6B9LzV/VqGQK/IrFewq+EG+ePVrE9Tpc3fg==", + "license": "MIT" }, "node_modules/html-entities": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", - "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", "funding": [ { "type": "github", @@ -9309,88 +9857,86 @@ "type": "patreon", "url": "https://patreon.com/mdevils" } - ] + ], + "license": "MIT" }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "license": "MIT" }, - "node_modules/html-minifier": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-4.0.0.tgz", - "integrity": "sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==", - "dependencies": { - "camel-case": "^3.0.0", - "clean-css": "^4.2.1", - "commander": "^2.19.0", - "he": "^1.2.0", - "param-case": "^2.1.1", + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", "relateurl": "^0.2.7", - "uglify-js": "^3.5.1" + "terser": "^5.15.1" }, "bin": { - "html-minifier": "cli.js" + "html-minifier-terser": "cli.js" }, "engines": { - "node": ">=6" + "node": "^14.13.1 || >=16.0.0" } }, - "node_modules/html-minifier-terser": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz", - "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==", - "dependencies": { - "camel-case": "^4.1.1", - "clean-css": "^4.2.3", - "commander": "^4.1.1", - "he": "^1.2.0", - "param-case": "^3.0.3", - "relateurl": "^0.2.7", - "terser": "^4.6.3" - }, + "node_modules/html-minifier-terser/node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "license": "MIT", "bin": { - "html-minifier-terser": "cli.js" + "acorn": "bin/acorn" }, "engines": { - "node": ">=6" - } - }, - "node_modules/html-minifier-terser/node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" + "node": ">=0.4.0" } }, "node_modules/html-minifier-terser/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=14" } }, - "node_modules/html-minifier-terser/node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "node_modules/html-minifier-terser/node_modules/terser": { + "version": "5.31.6", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", + "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", + "license": "BSD-2-Clause", "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" } }, - "node_modules/html-minifier-terser/node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" }, "node_modules/html-tags": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-2.0.0.tgz", "integrity": "sha512-+Il6N8cCo2wB/Vd3gqy/8TZhTD3QvcVeQLCnZiGkGCH3JP28IgGAY41giccp2W4R3jfyJPAP318FQTa1yU7K7g==", + "license": "MIT", "engines": { "node": ">=4" } @@ -9399,6 +9945,7 @@ "version": "4.5.2", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz", "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==", + "license": "MIT", "dependencies": { "@types/html-minifier-terser": "^5.0.0", "@types/tapable": "^1.0.5", @@ -9417,6 +9964,48 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/html-webpack-plugin/node_modules/clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/html-webpack-plugin/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/html-webpack-plugin/node_modules/html-minifier-terser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz", + "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.1", + "clean-css": "^4.2.3", + "commander": "^4.1.1", + "he": "^1.2.0", + "param-case": "^3.0.3", + "relateurl": "^0.2.7", + "terser": "^4.6.3" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -9428,6 +10017,7 @@ "url": "https://github.com/sponsors/fb55" } ], + "license": "MIT", "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.0.0", @@ -9439,6 +10029,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", @@ -9452,6 +10043,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.2.0" }, @@ -9466,6 +10058,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", @@ -9479,6 +10072,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } @@ -9538,7 +10132,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", "integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==", - "dev": true, + "optional": true, "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^2.0.2", @@ -9561,6 +10155,15 @@ "node": ">=10.17.0" } }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9576,6 +10179,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", "engines": { "node": "^10 || ^12 || >= 14" }, @@ -9608,9 +10212,10 @@ "integrity": "sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA==" }, "node_modules/ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", "engines": { "node": ">= 4" } @@ -9624,6 +10229,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -9635,25 +10241,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-fresh/node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "engines": { - "node": ">=4" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -9718,11 +10305,12 @@ } }, "node_modules/internal-slot": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", - "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2", + "es-errors": "^1.3.0", "hasown": "^2.0.0", "side-channel": "^1.0.4" }, @@ -9731,9 +10319,10 @@ } }, "node_modules/ip": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz", - "integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", + "license": "MIT" }, "node_modules/is-accessor-descriptor": { "version": "1.0.1", @@ -9747,13 +10336,16 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9762,12 +10354,14 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" }, "node_modules/is-bigint": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "license": "MIT", "dependencies": { "has-bigints": "^1.0.1" }, @@ -9790,6 +10384,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -9810,6 +10405,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -9821,7 +10417,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, + "optional": true, "dependencies": { "ci-info": "^3.2.0" }, @@ -9851,10 +10447,26 @@ "node": ">= 0.4" } }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "license": "MIT", + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-date-object": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -9919,7 +10531,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "dev": true, + "optional": true, "dependencies": { "global-dirs": "^3.0.0", "is-path-inside": "^3.0.2" @@ -9932,9 +10544,10 @@ } }, "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -9954,6 +10567,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -9968,7 +10582,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, + "optional": true, "engines": { "node": ">=8" } @@ -9999,6 +10613,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -10022,11 +10637,15 @@ } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10055,6 +10674,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -10069,6 +10689,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "license": "MIT", "dependencies": { "has-symbols": "^1.0.2" }, @@ -10080,11 +10701,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -10097,13 +10719,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true + "optional": true }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, + "optional": true, "engines": { "node": ">=10" }, @@ -10115,6 +10737,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2" }, @@ -10152,7 +10775,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true + "optional": true }, "node_modules/jackspeak": { "version": "2.3.6", @@ -10176,6 +10799,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "license": "MIT", "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -10192,9 +10816,10 @@ "dev": true }, "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "license": "MIT", "bin": { "jiti": "bin/jiti.js" } @@ -10202,18 +10827,20 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" }, "node_modules/jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true + "optional": true }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -10229,13 +10856,14 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true + "optional": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -10246,7 +10874,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true + "optional": true }, "node_modules/json5": { "version": "1.0.2", @@ -10274,10 +10902,10 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "dev": true, "engines": [ "node >=0.6.0" ], + "optional": true, "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -10308,6 +10936,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", "engines": { "node": ">= 8" } @@ -10316,33 +10945,36 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz", "integrity": "sha512-7KI2l2GIZa9p2spzPIVZBYyNKkN+e/SQPpnjlTiPhdbDW3F86tdKKELxKpzJ5sgU19wQWsACULZmpTPYHeWO5w==", + "license": "MIT", "dependencies": { "lodash": "^4.17.5", "webpack-sources": "^1.1.0" } }, "node_modules/launch-editor": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", - "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.8.2.tgz", + "integrity": "sha512-eF5slEUZXmi6WvFzI3dYcv+hA24/iKnROf24HztcURJpSz9RBmBgz5cNCVOeguouf1llrwy6Yctl4C4HM+xI8g==", + "license": "MIT", "dependencies": { "picocolors": "^1.0.0", "shell-quote": "^1.8.1" } }, "node_modules/launch-editor-middleware": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/launch-editor-middleware/-/launch-editor-middleware-2.6.1.tgz", - "integrity": "sha512-Fg/xYhf7ARmRp40n18wIfJyuAMEjXo67Yull7uF7d0OJ3qA4EYJISt1XfPPn69IIJ5jKgQwzcg6DqHYo95LL/g==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/launch-editor-middleware/-/launch-editor-middleware-2.8.1.tgz", + "integrity": "sha512-GWhcsTuzgRQguiiei0BrbLX7rS5Pcj+6VyynZlS7zlnmUvVAUUBnJjILIhuIgWJXn1WSMtkfHAkXFSoQeJvwdQ==", + "license": "MIT", "dependencies": { - "launch-editor": "^2.6.1" + "launch-editor": "^2.8.1" } }, "node_modules/lazy-ass": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", - "dev": true, + "optional": true, "engines": { "node": "> 0.8" } @@ -10361,11 +10993,15 @@ } }, "node_modules/lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "license": "MIT", "engines": { "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, "node_modules/lines-and-columns": { @@ -10377,7 +11013,7 @@ "version": "3.14.0", "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", - "dev": true, + "optional": true, "dependencies": { "cli-truncate": "^2.1.0", "colorette": "^2.0.16", @@ -10404,7 +11040,7 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, + "optional": true, "dependencies": { "tslib": "^2.1.0" } @@ -10413,12 +11049,13 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "optional": true }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "license": "MIT", "engines": { "node": ">=6.11.5" } @@ -10456,6 +11093,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", "dependencies": { "p-locate": "^4.1.0" }, @@ -10476,23 +11114,26 @@ "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==" + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "license": "MIT" }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true + "optional": true }, "node_modules/lodash.template": { "version": "4.5.0", @@ -10514,13 +11155,14 @@ "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, + "optional": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -10536,7 +11178,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", - "dev": true, + "optional": true, "dependencies": { "ansi-escapes": "^4.3.0", "cli-cursor": "^3.1.0", @@ -10554,7 +11196,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, + "optional": true, "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -10571,7 +11213,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, + "optional": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -10582,9 +11224,19 @@ } }, "node_modules/lower-case": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", - "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==" + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lower-case/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -10606,6 +11258,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", "dependencies": { "semver": "^6.0.0" }, @@ -10620,6 +11273,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -10647,6 +11301,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "license": "MIT", "dependencies": { "repeat-string": "^1.0.0" }, @@ -10673,19 +11328,116 @@ "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0" }, "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.11.1.tgz", + "integrity": "sha512-LZcMTBAgqUUKNXZagcZxvXXfgF1bHX7Y7nQ0QyEiNbRJgE29GhgPd8Yna1VQcLlPiHt/5RFJMWYN9Uv/VPNvjQ==", + "license": "Apache-2.0", "dependencies": { - "fs-monkey": "^1.0.4" + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" }, "engines": { "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/memfs/node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/memfs/node_modules/@jsonjoy.com/json-pack": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.0.tgz", + "integrity": "sha512-zlQONA+msXPPwHWZMKFVS78ewFczIll5lXiVPwFPCZUsrOKdxc2AvxU1HoNBmMRhqDZUR9HkC3UOm+6pME6Xsg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/memfs/node_modules/@jsonjoy.com/util": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.3.0.tgz", + "integrity": "sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, + "node_modules/memfs/node_modules/thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "license": "Unlicense", + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/memfs/node_modules/tree-dump": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", + "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/memfs/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, "node_modules/memory-fs": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", @@ -10702,6 +11454,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", + "license": "MIT", "dependencies": { "source-map": "^0.6.1" } @@ -10752,6 +11505,7 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==", + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -10800,6 +11554,7 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -10819,6 +11574,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -10830,6 +11586,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -10841,6 +11598,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -10852,6 +11610,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -10862,12 +11621,14 @@ "node_modules/minipass/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, "node_modules/minizlib": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -10879,7 +11640,8 @@ "node_modules/minizlib/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, "node_modules/mississippi": { "version": "3.0.0", @@ -10941,6 +11703,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "license": "MIT", "engines": { "node": ">=10" } @@ -10954,6 +11717,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/mustache/-/mustache-2.3.2.tgz", "integrity": "sha512-KpMNwdQsYz3O/SBS1qJ/o3sqUJ5wSb8gb0pul8CO0S56b9Y2ALm8zCfsjPXsqGFfoNBkDwZuZIAjhsZI03gYVQ==", + "license": "MIT", "bin": { "mustache": "bin/mustache" }, @@ -11040,13 +11804,21 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, "node_modules/no-case": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", - "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", "dependencies": { - "lower-case": "^1.1.1" + "lower-case": "^2.0.2", + "tslib": "^2.0.3" } }, + "node_modules/no-case/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -11067,14 +11839,16 @@ } }, "node_modules/node-fetch-native": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.1.tgz", - "integrity": "sha512-bW9T/uJDPAJB2YNYEpWzE54U5O3MQidXsOyTfnbKYtTtFexRvGzb1waphBN4ZwP6EcIvYYEOwW0b72BpAqydTw==" + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz", + "integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==", + "license": "MIT" }, "node_modules/node-html-parser": { - "version": "6.1.12", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.12.tgz", - "integrity": "sha512-/bT/Ncmv+fbMGX96XG9g05vFt43m/+SYKIs9oAemQVYyVcZmDAI2Xq/SbNcpOA35eF0Zk2av3Ksf+Xk8Vt8abA==", + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "license": "MIT", "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" @@ -11129,14 +11903,16 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/node-object-hash/-/node-object-hash-1.4.2.tgz", "integrity": "sha512-UdS4swXs85fCGWWf6t6DMGgpN/vnlKeSGEQ7hJcrs7PBFoxoKLmibc3QRb7fwiYsjdL7PX8iI/TMSlZ90dgHhQ==", + "license": "ISC", "engines": { "node": ">=0.10.0" } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "license": "MIT" }, "node_modules/node-res": { "version": "5.0.1", @@ -11170,6 +11946,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.9.1.tgz", "integrity": "sha512-A48My/mtCklowHBlI8Fq2jFWK4tX4lJ5E6ytFsSOq1fzpvT0SQSgKhSg7lN5c2uYFOrUAOQp6zhhJnpp1eMloQ==", + "license": "MIT", "dependencies": { "object-assign": "^4.0.1", "prepend-http": "^1.0.0", @@ -11195,6 +11972,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" }, @@ -11203,26 +11981,28 @@ } }, "node_modules/nuxt": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-2.17.3.tgz", - "integrity": "sha512-mQUy0J2DYYxHZvgBX8YvrrM8sKUhBqBxcQ0ePjy7cdyTaDAN8QeOLrizINm7NVPMrFGLYurhp5rbX3/qyQcKyg==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-2.18.1.tgz", + "integrity": "sha512-SZFOLDKgCfLu23BrQE0YYNWeoi/h+fw07TNDNDzRfbmMvQlStgTBG7lqeELytXdQnaPKWjWAYo12K7pPPRZb9Q==", + "deprecated": "Nuxt 2 has reached EOL and is no longer actively maintained. See https://nuxt.com/blog/nuxt2-eol for more details.", "hasInstallScript": true, + "license": "MIT", "dependencies": { - "@nuxt/babel-preset-app": "2.17.3", - "@nuxt/builder": "2.17.3", - "@nuxt/cli": "2.17.3", + "@nuxt/babel-preset-app": "2.18.1", + "@nuxt/builder": "2.18.1", + "@nuxt/cli": "2.18.1", "@nuxt/components": "^2.2.1", - "@nuxt/config": "2.17.3", - "@nuxt/core": "2.17.3", - "@nuxt/generator": "2.17.3", + "@nuxt/config": "2.18.1", + "@nuxt/core": "2.18.1", + "@nuxt/generator": "2.18.1", "@nuxt/loading-screen": "^2.0.4", "@nuxt/opencollective": "^0.4.0", - "@nuxt/server": "2.17.3", + "@nuxt/server": "2.18.1", "@nuxt/telemetry": "^1.5.0", - "@nuxt/utils": "2.17.3", - "@nuxt/vue-app": "2.17.3", - "@nuxt/vue-renderer": "2.17.3", - "@nuxt/webpack": "2.17.3" + "@nuxt/utils": "2.18.1", + "@nuxt/vue-app": "2.18.1", + "@nuxt/vue-renderer": "2.18.1", + "@nuxt/webpack": "2.18.1" }, "bin": { "nuxt": "bin/nuxt.js" @@ -11388,15 +12168,18 @@ } }, "node_modules/object.getownpropertydescriptors": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.7.tgz", - "integrity": "sha512-PrJz0C2xJ58FNn11XV2lr4Jt5Gzl94qpy9Lu0JlfEj14z88sqbSBJCBEzdlNUCzY2gburhbrwOZ5BHCmuNUy0g==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.8.tgz", + "integrity": "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==", + "license": "MIT", "dependencies": { "array.prototype.reduce": "^1.0.6", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "safe-array-concat": "^1.0.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "gopd": "^1.0.1", + "safe-array-concat": "^1.1.2" }, "engines": { "node": ">= 0.8" @@ -11431,6 +12214,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -11461,6 +12245,7 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "license": "(WTFPL OR MIT)", "bin": { "opener": "bin/opener-bin.js" } @@ -11469,6 +12254,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-6.0.1.tgz", "integrity": "sha512-BshV2UZPfggZLdUfN3zFBbG4sl/DynUI+YCB6fRRDWaqO2OiWN8GPcp4Y0/fEV6B3k9Hzyk3czve3V/8B/SzKQ==", + "license": "MIT", "dependencies": { "cssnano": "^5.0.2", "last-call-webpack-plugin": "^3.0.0", @@ -11482,6 +12268,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", "engines": { "node": ">= 10" } @@ -11490,6 +12277,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", + "license": "ISC", "engines": { "node": "^10 || ^12 || >=14" }, @@ -11501,6 +12289,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", @@ -11516,6 +12305,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -11528,6 +12318,7 @@ "version": "5.1.15", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "license": "MIT", "dependencies": { "cssnano-preset-default": "^5.2.14", "lilconfig": "^2.0.3", @@ -11548,6 +12339,7 @@ "version": "5.2.14", "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "license": "MIT", "dependencies": { "css-declaration-sorter": "^6.3.1", "cssnano-utils": "^3.1.0", @@ -11590,6 +12382,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "license": "MIT", "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -11601,6 +12394,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "license": "MIT", "dependencies": { "css-tree": "^1.1.2" }, @@ -11612,6 +12406,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", @@ -11625,6 +12420,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.2.0" }, @@ -11639,6 +12435,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", @@ -11652,6 +12449,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } @@ -11660,6 +12458,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "license": "MIT", "engines": { "node": ">=10" } @@ -11667,12 +12466,14 @@ "node_modules/optimize-css-assets-webpack-plugin/node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" }, "node_modules/optimize-css-assets-webpack-plugin/node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -11684,6 +12485,7 @@ "version": "8.2.4", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "license": "MIT", "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" @@ -11696,6 +12498,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "license": "MIT", "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", @@ -11713,6 +12516,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "license": "MIT", "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" @@ -11728,6 +12532,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "license": "MIT", "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -11739,6 +12544,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "license": "MIT", "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -11750,6 +12556,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "license": "MIT", "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -11761,6 +12568,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "license": "MIT", "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -11772,6 +12580,7 @@ "version": "5.1.7", "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", "stylehacks": "^5.1.1" @@ -11787,6 +12596,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "license": "MIT", "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", @@ -11804,6 +12614,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -11818,6 +12629,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "license": "MIT", "dependencies": { "colord": "^2.9.1", "cssnano-utils": "^3.1.0", @@ -11834,6 +12646,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "license": "MIT", "dependencies": { "browserslist": "^4.21.4", "cssnano-utils": "^3.1.0", @@ -11850,6 +12663,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "license": "MIT", "dependencies": { "postcss-selector-parser": "^6.0.5" }, @@ -11864,6 +12678,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "license": "MIT", "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -11875,6 +12690,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -11889,6 +12705,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -11903,6 +12720,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -11917,6 +12735,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -11931,6 +12750,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -11945,6 +12765,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "license": "MIT", "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" @@ -11960,6 +12781,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "license": "MIT", "dependencies": { "normalize-url": "^6.0.1", "postcss-value-parser": "^4.2.0" @@ -11975,6 +12797,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -11989,6 +12812,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "license": "MIT", "dependencies": { "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" @@ -12004,6 +12828,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "license": "MIT", "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0" @@ -12019,6 +12844,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -12033,6 +12859,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", "svgo": "^2.7.0" @@ -12048,6 +12875,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "license": "MIT", "dependencies": { "postcss-selector-parser": "^6.0.5" }, @@ -12062,6 +12890,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "license": "MIT", "dependencies": { "browserslist": "^4.21.4", "postcss-selector-parser": "^6.0.4" @@ -12077,6 +12906,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "license": "MIT", "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", @@ -12110,7 +12940,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", - "dev": true + "optional": true }, "node_modules/p-limit": { "version": "2.3.0", @@ -12130,6 +12960,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", "dependencies": { "p-limit": "^2.2.0" }, @@ -12175,11 +13006,31 @@ } }, "node_modules/param-case": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", - "integrity": "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/param-case/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", "dependencies": { - "no-case": "^2.2.0" + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" } }, "node_modules/parse-asn1": { @@ -12210,6 +13061,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "license": "MIT", "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" @@ -12246,32 +13098,17 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, - "node_modules/pascal-case/node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/pascal-case/node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, "node_modules/pascal-case/node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" }, "node_modules/pascalcase": { "version": "0.1.1", @@ -12296,6 +13133,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", "engines": { "node": ">=8" } @@ -12395,18 +13233,19 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "optional": true }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true + "optional": true }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -12423,6 +13262,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -12443,6 +13283,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "license": "MIT", "dependencies": { "find-up": "^4.0.0" }, @@ -12454,6 +13295,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz", "integrity": "sha512-2Rb3vm+EXble/sMXNSu6eoBx8e79gKqhNq9F5ZWW6ERNCTE/Q0wQNne5541tE5vKjfM8hpNCYL+LGc1YTfI0dg==", + "license": "MIT", "dependencies": { "ts-pnp": "^1.1.6" }, @@ -12469,10 +13311,19 @@ "node": ">=0.10.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { - "version": "8.4.33", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", - "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "version": "8.4.44", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.44.tgz", + "integrity": "sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==", "funding": [ { "type": "opencollective", @@ -12487,52 +13338,62 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/postcss-attribute-case-insensitive": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-6.0.2.tgz", - "integrity": "sha512-IRuCwwAAQbgaLhxQdQcIIK0dCVXg3XDUnzgKD8iwdiYdwU4rMWRWyl/W9/0nA4ihVpq5pyALiHB2veBJ0292pw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-6.0.3.tgz", + "integrity": "sha512-KHkmCILThWBRtg+Jn1owTnHPnFit4OkqS+eKiGEOPIGke54DCeYGJ6r0Fx/HjfE9M9kznApCLcU0DvnPchazMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^6.0.13" }, "engines": { "node": "^14 || ^16 || >=18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/postcss-calc": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz", - "integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.0.2.tgz", + "integrity": "sha512-DT/Wwm6fCKgpYVI7ZEWuPJ4az8hiEHtCUeYjZXqU7Ou4QqYh1Df2yCQ7Ca6N7xqKPFkxN3fhf+u9KSoOCJNAjg==", + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.11", + "postcss-selector-parser": "^6.1.2", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12 || ^20.9 || >=22.0" }, "peerDependencies": { - "postcss": "^8.2.2" + "postcss": "^8.4.38" } }, "node_modules/postcss-clamp": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -12544,9 +13405,9 @@ } }, "node_modules/postcss-color-functional-notation": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-6.0.4.tgz", - "integrity": "sha512-YBzfVvVUNR4U3N0imzU1NPKCuwxzfHJkEP6imJxzsJ8LozRKeej9mWmg9Ef1ovJdb0xrGTRVzUxgTrMun5iw/Q==", + "version": "6.0.14", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-6.0.14.tgz", + "integrity": "sha512-dNUX+UH4dAozZ8uMHZ3CtCNYw8fyFAmqqdcyxMr7PEdM9jLXV19YscoYO0F25KqZYhmtWKQ+4tKrIZQrwzwg7A==", "funding": [ { "type": "github", @@ -12557,11 +13418,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/postcss-progressive-custom-properties": "^3.0.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -12571,9 +13434,9 @@ } }, "node_modules/postcss-color-hex-alpha": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-9.0.3.tgz", - "integrity": "sha512-7sEHU4tAS6htlxun8AB9LDrCXoljxaC34tFVRlYKcvO+18r5fvGiXgv5bQzN40+4gXLCyWSMRK5FK31244WcCA==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-9.0.4.tgz", + "integrity": "sha512-XQZm4q4fNFqVCYMGPiBjcqDhuG7Ey2xrl99AnDJMyr5eDASsAGalndVgHZF8i97VFNy1GQeZc4q2ydagGmhelQ==", "funding": [ { "type": "github", @@ -12584,7 +13447,9 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { + "@csstools/utilities": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -12595,9 +13460,9 @@ } }, "node_modules/postcss-color-rebeccapurple": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-9.0.2.tgz", - "integrity": "sha512-f+RDEAPW2m8UbJWkSpRfV+QxhSaQhDMihI75DVGJJh4oRIoegjheeRtINFJum9D8BqGJcvD4GLjggTvCwZ4zuA==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-9.0.3.tgz", + "integrity": "sha512-ruBqzEFDYHrcVq3FnW3XHgwRqVMrtEPLBtD7K2YmsLKVc2jbkxzzNEctJKsPCpDZ+LeMHLKRDoSShVefGc+CkQ==", "funding": [ { "type": "github", @@ -12608,7 +13473,9 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { + "@csstools/utilities": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -12619,41 +13486,43 @@ } }, "node_modules/postcss-colormin": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.0.2.tgz", - "integrity": "sha512-TXKOxs9LWcdYo5cgmcSHPkyrLAh86hX1ijmyy6J8SbOhyv6ua053M3ZAM/0j44UsnQNIWdl8gb5L7xX2htKeLw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.2.tgz", + "integrity": "sha512-YntRXNngcvEvDbEjTdRWGU606eZvB5prmHG4BF0yLmVpamXbpsRJzevyy6MZVyuecgzI2AWAlvFi8DAeCqwpvA==", + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.3", "caniuse-api": "^3.0.0", - "colord": "^2.9.1", + "colord": "^2.9.3", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-convert-values": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.0.2.tgz", - "integrity": "sha512-aeBmaTnGQ+NUSVQT8aY0sKyAD/BaLJenEKZ03YK0JnDE1w1Rr8XShoxdal2V2H26xTJKr3v5haByOhJuyT4UYw==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.3.tgz", + "integrity": "sha512-yJhocjCs2SQer0uZ9lXTMOwDowbxvhwFVrZeS6NPEij/XXthl73ggUmfwVvJM+Vaj5gtCKJV1jiUu4IhAUkX/Q==", + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.3", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-custom-media": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.2.tgz", - "integrity": "sha512-zcEFNRmDm2fZvTPdI1pIW3W//UruMcLosmMiCdpQnrCsTRzWlKQPYMa1ud9auL0BmrryKK1+JjIGn19K0UjO/w==", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.8.tgz", + "integrity": "sha512-V1KgPcmvlGdxTel4/CyQtBJEFhMVpEmRGFrnVtgfGIHj5PJX9vO36eFBxKBeJn+aCDTed70cc+98Mz3J/uVdGQ==", "funding": [ { "type": "github", @@ -12664,11 +13533,12 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.5", - "@csstools/css-parser-algorithms": "^2.3.2", - "@csstools/css-tokenizer": "^2.2.1", - "@csstools/media-query-list-parser": "^2.1.5" + "@csstools/cascade-layer-name-parser": "^1.0.13", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/media-query-list-parser": "^2.1.13" }, "engines": { "node": "^14 || ^16 || >=18" @@ -12678,9 +13548,9 @@ } }, "node_modules/postcss-custom-properties": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-13.3.4.tgz", - "integrity": "sha512-9YN0gg9sG3OH+Z9xBrp2PWRb+O4msw+5Sbp3ZgqrblrwKspXVQe5zr5sVqi43gJGwW/Rv1A483PRQUzQOEewvA==", + "version": "13.3.12", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-13.3.12.tgz", + "integrity": "sha512-oPn/OVqONB2ZLNqN185LDyaVByELAA/u3l2CS2TS16x2j2XsmV4kd8U49+TMxmUsEU9d8fB/I10E6U7kB0L1BA==", "funding": [ { "type": "github", @@ -12691,10 +13561,12 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.7", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", + "@csstools/cascade-layer-name-parser": "^1.0.13", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/utilities": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -12705,9 +13577,9 @@ } }, "node_modules/postcss-custom-selectors": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-7.1.6.tgz", - "integrity": "sha512-svsjWRaxqL3vAzv71dV0/65P24/FB8TbPX+lWyyf9SZ7aZm4S4NhCn7N3Bg+Z5sZunG3FS8xQ80LrCU9hb37cw==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-7.1.12.tgz", + "integrity": "sha512-ctIoprBMJwByYMGjXG0F7IT2iMF2hnamQ+aWZETyBM0aAlyaYdVZTeUkk8RB+9h9wP+NdN3f01lfvKl2ZSqC0g==", "funding": [ { "type": "github", @@ -12718,11 +13590,12 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.5", - "@csstools/css-parser-algorithms": "^2.3.2", - "@csstools/css-tokenizer": "^2.2.1", - "postcss-selector-parser": "^6.0.13" + "@csstools/cascade-layer-name-parser": "^1.0.13", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "postcss-selector-parser": "^6.1.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -12745,6 +13618,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^6.0.13" }, @@ -12756,53 +13630,60 @@ } }, "node_modules/postcss-discard-comments": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.1.tgz", - "integrity": "sha512-f1KYNPtqYLUeZGCHQPKzzFtsHaRuECe6jLakf/RjSRqvF5XHLZnM2+fXLhb8Qh/HBFHs3M4cSLb1k3B899RYIg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.2.tgz", + "integrity": "sha512-/Hje9Ls1IYcB9duELO/AyDUJI6aQVY3h5Rj1ziXgaLYCTi1iVBLnjg/TS0D6NszR/kDG6I86OwLmAYe+bvJjiQ==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-discard-duplicates": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.1.tgz", - "integrity": "sha512-1hvUs76HLYR8zkScbwyJ8oJEugfPV+WchpnA+26fpJ7Smzs51CzGBHC32RS03psuX/2l0l0UKh2StzNxOrKCYg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.1.tgz", + "integrity": "sha512-oZA+v8Jkpu1ct/xbbrntHRsfLGuzoP+cpt0nJe5ED2FQF8n8bJtn7Bo28jSmBYwqgqnqkuSXJfSUEE7if4nClQ==", + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-discard-empty": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.1.tgz", - "integrity": "sha512-yitcmKwmVWtNsrrRqGJ7/C0YRy53i0mjexBDQ9zYxDwTWVBgbU4+C9jIZLmQlTDT9zhml+u0OMFJh8+31krmOg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.0.tgz", + "integrity": "sha512-e+QzoReTZ8IAwhnSdp/++7gBZ/F+nBq9y6PomfwORfP7q9nBpK5AMP64kOt0bA+lShBFbBDcgpJ3X4etHg4lzA==", + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-discard-overridden": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.1.tgz", - "integrity": "sha512-qs0ehZMMZpSESbRkw1+inkf51kak6OOzNRaoLd/U7Fatp0aN2HQ1rxGOrJvYcRAN9VpX8kUF13R2ofn8OlvFVA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.0.tgz", + "integrity": "sha512-GmNAzx88u3k2+sBTZrJSDauR0ccpE24omTQCVmaTTZFz1du6AasspjaUPMJ2ud4RslZpoFKyf+6MSPETLojc6w==", + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-double-position-gradients": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-5.0.3.tgz", - "integrity": "sha512-QKYpwmaSm6HcdS0ndAuWSNNMv78R1oSySoh3mYBmctHWr2KWcwPJVakdOyU4lvFVW0GRu9wfIQwGeM4p3xU9ow==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-5.0.7.tgz", + "integrity": "sha512-1xEhjV9u1s4l3iP5lRt1zvMjI/ya8492o9l/ivcxHhkO3nOz16moC4JpMxDUGrOs4R3hX+KWT7gKoV842cwRgg==", "funding": [ { "type": "github", @@ -12813,8 +13694,10 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^3.0.3", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -12838,6 +13721,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^6.0.13" }, @@ -12862,6 +13746,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^6.0.13" }, @@ -12876,6 +13761,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "license": "MIT", "peerDependencies": { "postcss": "^8.1.0" } @@ -12894,6 +13780,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": "^14 || ^16 || >=18" }, @@ -12902,9 +13789,9 @@ } }, "node_modules/postcss-image-set-function": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-6.0.2.tgz", - "integrity": "sha512-/O1xwqpJiz/apxGQi7UUfv1xUcorvkHZfvCYHPpRxxZj2WvjD0rg0+/+c+u5/Do5CpUg3XvfYxMrhcnjW1ArDQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-6.0.3.tgz", + "integrity": "sha512-i2bXrBYzfbRzFnm+pVuxVePSTCRiNmlfssGI4H0tJQvDue+yywXwUxe68VyzXs7cGtMaH6MCLY6IbCShrSroCw==", "funding": [ { "type": "github", @@ -12915,20 +13802,40 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=14.0.0" }, "peerDependencies": { - "postcss": "^8.4" + "postcss": "^8.0.0" } }, "node_modules/postcss-import-resolver": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postcss-import-resolver/-/postcss-import-resolver-2.0.0.tgz", "integrity": "sha512-y001XYgGvVwgxyxw9J1a5kqM/vtmIQGzx34g0A0Oy44MFcy/ZboZw1hu/iN3VYFjSTRzbvd7zZJJz0Kh0AGkTw==", + "license": "MIT", "dependencies": { "enhanced-resolve": "^4.1.1" } @@ -12953,9 +13860,9 @@ } }, "node_modules/postcss-lab-function": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.9.tgz", - "integrity": "sha512-PKFAVTBEWJYsoSTD7Kp/OzeiMsXaLX39Pv75XgUyF5VrbMfeTw+JqCGsvDP3dPhclh6BemdCFHcjXBG9gO4UCg==", + "version": "6.0.19", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.19.tgz", + "integrity": "sha512-vwln/mgvFrotJuGV8GFhpAOu9iGf3pvTBr6dLPDmUcqVD5OsQpEFyQMAFTxSxWXGEzBj6ld4pZ/9GDfEpXvo0g==", "funding": [ { "type": "github", @@ -12966,11 +13873,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.5.1", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/postcss-progressive-custom-properties": "^3.0.3" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -13027,6 +13936,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-4.3.0.tgz", "integrity": "sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==", + "license": "MIT", "dependencies": { "cosmiconfig": "^7.0.0", "klona": "^2.0.4", @@ -13050,6 +13960,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -13061,6 +13972,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -13084,6 +13996,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13095,101 +14008,109 @@ } }, "node_modules/postcss-merge-longhand": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.2.tgz", - "integrity": "sha512-+yfVB7gEM8SrCo9w2lCApKIEzrTKl5yS1F4yGhV3kSim6JzbfLGJyhR1B6X+6vOT0U33Mgx7iv4X9MVWuaSAfw==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.3.tgz", + "integrity": "sha512-8waYomFxshdv6M9Em3QRM9MettRLDRcH2JQi2l0Z1KlYD/vhal3gbkeSES0NuACXOlZBB0V/B0AseHZaklzWOA==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", - "stylehacks": "^6.0.2" + "stylehacks": "^7.0.3" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-merge-rules": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.0.3.tgz", - "integrity": "sha512-yfkDqSHGohy8sGYIJwBmIGDv4K4/WrJPX355XrxQb/CSsT4Kc/RxDi6akqn5s9bap85AWgv21ArcUWwWdGNSHA==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.3.tgz", + "integrity": "sha512-2eSas2p3voPxNfdI5sQrvIkMaeUHpVc3EezgVs18hz/wRTQAC9U99tp9j3W5Jx9/L3qHkEDvizEx/LdnmumIvQ==", + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.3", "caniuse-api": "^3.0.0", - "cssnano-utils": "^4.0.1", - "postcss-selector-parser": "^6.0.15" + "cssnano-utils": "^5.0.0", + "postcss-selector-parser": "^6.1.1" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-minify-font-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.0.1.tgz", - "integrity": "sha512-tIwmF1zUPoN6xOtA/2FgVk1ZKrLcCvE0dpZLtzyyte0j9zUeB8RTbCqrHZGjJlxOvNWKMYtunLrrl7HPOiR46w==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.0.tgz", + "integrity": "sha512-2ckkZtgT0zG8SMc5aoNwtm5234eUx1GGFJKf2b1bSp8UflqaeFzR50lid4PfqVI9NtGqJ2J4Y7fwvnP/u1cQog==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-minify-gradients": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.1.tgz", - "integrity": "sha512-M1RJWVjd6IOLPl1hYiOd5HQHgpp6cvJVLrieQYS9y07Yo8itAr6jaekzJphaJFR0tcg4kRewCk3kna9uHBxn/w==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.0.tgz", + "integrity": "sha512-pdUIIdj/C93ryCHew0UgBnL2DtUS3hfFa5XtERrs4x+hmpMYGhbzo6l/Ir5de41O0GaKVpK1ZbDNXSY6GkXvtg==", + "license": "MIT", "dependencies": { - "colord": "^2.9.1", - "cssnano-utils": "^4.0.1", + "colord": "^2.9.3", + "cssnano-utils": "^5.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-minify-params": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.0.2.tgz", - "integrity": "sha512-zwQtbrPEBDj+ApELZ6QylLf2/c5zmASoOuA4DzolyVGdV38iR2I5QRMsZcHkcdkZzxpN8RS4cN7LPskOkTwTZw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.2.tgz", + "integrity": "sha512-nyqVLu4MFl9df32zTsdcLqCFfE/z2+f8GE1KHPxWOAmegSo6lpV2GNy5XQvrzwbLmiU7d+fYay4cwto1oNdAaQ==", + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", - "cssnano-utils": "^4.0.1", + "browserslist": "^4.23.3", + "cssnano-utils": "^5.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-minify-selectors": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.2.tgz", - "integrity": "sha512-0b+m+w7OAvZejPQdN2GjsXLv5o0jqYHX3aoV0e7RBKPCsB7TYG5KKWBFhGnB/iP3213Ts8c5H4wLPLMm7z28Sg==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.3.tgz", + "integrity": "sha512-SxTgUQSgBk6wEqzQZKEv1xQYIp9UBju6no9q+npohzSdhuSICQdkqmD1UMKkZWItS3olJSJMDDEY9WOJ5oGJew==", + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.15" + "cssesc": "^3.0.0", + "postcss-selector-parser": "^6.1.1" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-modules-extract-imports": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", - "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", "engines": { "node": "^10 || ^12 || >= 14" }, @@ -13198,9 +14119,10 @@ } }, "node_modules/postcss-modules-local-by-default": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.4.tgz", - "integrity": "sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", + "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", + "license": "MIT", "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^6.0.2", @@ -13214,9 +14136,10 @@ } }, "node_modules/postcss-modules-scope": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.1.1.tgz", - "integrity": "sha512-uZgqzdTleelWjzJY+Fhti6F3C9iF1JR/dODLs/JDefozYcKTBCdD8BIl6nNPbTbcLnGrk56hzwZC2DaGNvYjzA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", + "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", + "license": "ISC", "dependencies": { "postcss-selector-parser": "^6.0.4" }, @@ -13231,6 +14154,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", "dependencies": { "icss-utils": "^5.0.0" }, @@ -13261,9 +14185,9 @@ } }, "node_modules/postcss-nesting": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-12.0.2.tgz", - "integrity": "sha512-63PpJHSeNs93S3ZUIyi+7kKx4JqOIEJ6QYtG3x+0qA4J03+4n0iwsyA1GAHyWxsHYljQS4/4ZK1o2sMi70b5wQ==", + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-12.1.5.tgz", + "integrity": "sha512-N1NgI1PDCiAGWPTYrwqm8wpjv0bgDmkYHH72pNsqTCv9CObxjxftdYu6AKtGN+pnJa7FQjMm3v4sp8QJbFsYdQ==", "funding": [ { "type": "github", @@ -13274,9 +14198,11 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/selector-specificity": "^3.0.1", - "postcss-selector-parser": "^6.0.13" + "@csstools/selector-resolve-nested": "^1.1.0", + "@csstools/selector-specificity": "^3.1.1", + "postcss-selector-parser": "^6.1.0" }, "engines": { "node": "^14 || ^16 || >=18" @@ -13286,124 +14212,133 @@ } }, "node_modules/postcss-normalize-charset": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.1.tgz", - "integrity": "sha512-aW5LbMNRZ+oDV57PF9K+WI1Z8MPnF+A8qbajg/T8PP126YrGX1f9IQx21GI2OlGz7XFJi/fNi0GTbY948XJtXg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.0.tgz", + "integrity": "sha512-ABisNUXMeZeDNzCQxPxBCkXexvBrUHV+p7/BXOY+ulxkcjUZO0cp8ekGBwvIh2LbCwnWbyMPNJVtBSdyhM2zYQ==", + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-normalize-display-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.1.tgz", - "integrity": "sha512-mc3vxp2bEuCb4LgCcmG1y6lKJu1Co8T+rKHrcbShJwUmKJiEl761qb/QQCfFwlrvSeET3jksolCR/RZuMURudw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.0.tgz", + "integrity": "sha512-lnFZzNPeDf5uGMPYgGOw7v0BfB45+irSRz9gHQStdkkhiM0gTfvWkWB5BMxpn0OqgOQuZG/mRlZyJxp0EImr2Q==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-normalize-positions": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.1.tgz", - "integrity": "sha512-HRsq8u/0unKNvm0cvwxcOUEcakFXqZ41fv3FOdPn916XFUrympjr+03oaLkuZENz3HE9RrQE9yU0Xv43ThWjQg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.0.tgz", + "integrity": "sha512-I0yt8wX529UKIGs2y/9Ybs2CelSvItfmvg/DBIjTnoUSrPxSV7Z0yZ8ShSVtKNaV/wAY+m7bgtyVQLhB00A1NQ==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-normalize-repeat-style": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.1.tgz", - "integrity": "sha512-Gbb2nmCy6tTiA7Sh2MBs3fj9W8swonk6lw+dFFeQT68B0Pzwp1kvisJQkdV6rbbMSd9brMlS8I8ts52tAGWmGQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.0.tgz", + "integrity": "sha512-o3uSGYH+2q30ieM3ppu9GTjSXIzOrRdCUn8UOMGNw7Af61bmurHTWI87hRybrP6xDHvOe5WlAj3XzN6vEO8jLw==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-normalize-string": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.1.tgz", - "integrity": "sha512-5Fhx/+xzALJD9EI26Aq23hXwmv97Zfy2VFrt5PLT8lAhnBIZvmaT5pQk+NuJ/GWj/QWaKSKbnoKDGLbV6qnhXg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.0.tgz", + "integrity": "sha512-w/qzL212DFVOpMy3UGyxrND+Kb0fvCiBBujiaONIihq7VvtC7bswjWgKQU/w4VcRyDD8gpfqUiBQ4DUOwEJ6Qg==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-normalize-timing-functions": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.1.tgz", - "integrity": "sha512-4zcczzHqmCU7L5dqTB9rzeqPWRMc0K2HoR+Bfl+FSMbqGBUcP5LRfgcH4BdRtLuzVQK1/FHdFoGT3F7rkEnY+g==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.0.tgz", + "integrity": "sha512-tNgw3YV0LYoRwg43N3lTe3AEWZ66W7Dh7lVEpJbHoKOuHc1sLrzMLMFjP8SNULHaykzsonUEDbKedv8C+7ej6g==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-normalize-unicode": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.0.2.tgz", - "integrity": "sha512-Ff2VdAYCTGyMUwpevTZPZ4w0+mPjbZzLLyoLh/RMpqUqeQKZ+xMm31hkxBavDcGKcxm6ACzGk0nBfZ8LZkStKA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.2.tgz", + "integrity": "sha512-ztisabK5C/+ZWBdYC+Y9JCkp3M9qBv/XFvDtSw0d/XwfT3UaKeW/YTm/MD/QrPNxuecia46vkfEhewjwcYFjkg==", + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.3", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-normalize-url": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.1.tgz", - "integrity": "sha512-jEXL15tXSvbjm0yzUV7FBiEXwhIa9H88JOXDGQzmcWoB4mSjZIsmtto066s2iW9FYuIrIF4k04HA2BKAOpbsaQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.0.tgz", + "integrity": "sha512-+d7+PpE+jyPX1hDQZYG+NaFD+Nd2ris6r8fPTBAjE8z/U41n/bib3vze8x7rKs5H1uEw5ppe9IojewouHk0klQ==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-normalize-whitespace": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.1.tgz", - "integrity": "sha512-76i3NpWf6bB8UHlVuLRxG4zW2YykF9CTEcq/9LGAiz2qBuX5cBStadkk0jSkg9a9TCIXbMQz7yzrygKoCW9JuA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.0.tgz", + "integrity": "sha512-37/toN4wwZErqohedXYqWgvcHUGlT8O/m2jVkAfAe9Bd4MzRqlBmXrJRePH0e9Wgnz2X7KymTgTOaaFizQe3AQ==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" @@ -13423,6 +14358,7 @@ "url": "https://liberapay.com/mrcgrtz" } ], + "license": "MIT", "engines": { "node": "^14 || ^16 || >=18" }, @@ -13431,15 +14367,16 @@ } }, "node_modules/postcss-ordered-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.1.tgz", - "integrity": "sha512-XXbb1O/MW9HdEhnBxitZpPFbIvDgbo9NK4c/5bOfiKpnIGZDoL2xd7/e6jW5DYLsWxBbs+1nZEnVgnjnlFViaA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.1.tgz", + "integrity": "sha512-irWScWRL6nRzYmBOXReIKch75RRhNS86UPUAxXdmW/l0FcAsg0lvAXQCby/1lymxn/o0gVa6Rv/0f03eJOwHxw==", + "license": "MIT", "dependencies": { - "cssnano-utils": "^4.0.1", + "cssnano-utils": "^5.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" @@ -13459,6 +14396,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13473,6 +14411,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "license": "MIT", "peerDependencies": { "postcss": "^8" } @@ -13491,6 +14430,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13502,9 +14442,9 @@ } }, "node_modules/postcss-preset-env": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.3.0.tgz", - "integrity": "sha512-ycw6doPrqV6QxDCtgiyGDef61bEfiSc59HGM4gOw/wxQxmKnhuEery61oOC/5ViENz/ycpRsuhTexs1kUBTvVw==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.6.0.tgz", + "integrity": "sha512-Lxfk4RYjUdwPCYkc321QMdgtdCP34AeI94z+/8kVmqnTIlD4bMRQeGcMZgwz8BxHrzQiFXYIR5d7k/9JMs2MEA==", "funding": [ { "type": "github", @@ -13515,67 +14455,69 @@ "url": "https://opencollective.com/csstools" } ], - "dependencies": { - "@csstools/postcss-cascade-layers": "^4.0.1", - "@csstools/postcss-color-function": "^3.0.7", - "@csstools/postcss-color-mix-function": "^2.0.7", - "@csstools/postcss-exponential-functions": "^1.0.1", - "@csstools/postcss-font-format-keywords": "^3.0.0", - "@csstools/postcss-gamut-mapping": "^1.0.0", - "@csstools/postcss-gradients-interpolation-method": "^4.0.7", - "@csstools/postcss-hwb-function": "^3.0.6", - "@csstools/postcss-ic-unit": "^3.0.2", - "@csstools/postcss-initial": "^1.0.0", - "@csstools/postcss-is-pseudo-class": "^4.0.3", - "@csstools/postcss-logical-float-and-clear": "^2.0.0", - "@csstools/postcss-logical-overflow": "^1.0.0", - "@csstools/postcss-logical-overscroll-behavior": "^1.0.0", - "@csstools/postcss-logical-resize": "^2.0.0", - "@csstools/postcss-logical-viewport-units": "^2.0.3", - "@csstools/postcss-media-minmax": "^1.1.0", - "@csstools/postcss-media-queries-aspect-ratio-number-values": "^2.0.3", - "@csstools/postcss-nested-calc": "^3.0.0", - "@csstools/postcss-normalize-display-values": "^3.0.1", - "@csstools/postcss-oklab-function": "^3.0.7", - "@csstools/postcss-progressive-custom-properties": "^3.0.2", - "@csstools/postcss-relative-color-syntax": "^2.0.7", - "@csstools/postcss-scope-pseudo-class": "^3.0.0", - "@csstools/postcss-stepped-value-functions": "^3.0.2", - "@csstools/postcss-text-decoration-shorthand": "^3.0.3", - "@csstools/postcss-trigonometric-functions": "^3.0.2", - "@csstools/postcss-unset-value": "^3.0.0", - "autoprefixer": "^10.4.16", - "browserslist": "^4.22.1", - "css-blank-pseudo": "^6.0.0", - "css-has-pseudo": "^6.0.0", - "css-prefers-color-scheme": "^9.0.0", - "cssdb": "^7.9.0", - "postcss-attribute-case-insensitive": "^6.0.2", + "license": "MIT-0", + "dependencies": { + "@csstools/postcss-cascade-layers": "^4.0.6", + "@csstools/postcss-color-function": "^3.0.19", + "@csstools/postcss-color-mix-function": "^2.0.19", + "@csstools/postcss-content-alt-text": "^1.0.0", + "@csstools/postcss-exponential-functions": "^1.0.9", + "@csstools/postcss-font-format-keywords": "^3.0.2", + "@csstools/postcss-gamut-mapping": "^1.0.11", + "@csstools/postcss-gradients-interpolation-method": "^4.0.20", + "@csstools/postcss-hwb-function": "^3.0.18", + "@csstools/postcss-ic-unit": "^3.0.7", + "@csstools/postcss-initial": "^1.0.1", + "@csstools/postcss-is-pseudo-class": "^4.0.8", + "@csstools/postcss-light-dark-function": "^1.0.8", + "@csstools/postcss-logical-float-and-clear": "^2.0.1", + "@csstools/postcss-logical-overflow": "^1.0.1", + "@csstools/postcss-logical-overscroll-behavior": "^1.0.1", + "@csstools/postcss-logical-resize": "^2.0.1", + "@csstools/postcss-logical-viewport-units": "^2.0.11", + "@csstools/postcss-media-minmax": "^1.1.8", + "@csstools/postcss-media-queries-aspect-ratio-number-values": "^2.0.11", + "@csstools/postcss-nested-calc": "^3.0.2", + "@csstools/postcss-normalize-display-values": "^3.0.2", + "@csstools/postcss-oklab-function": "^3.0.19", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/postcss-relative-color-syntax": "^2.0.19", + "@csstools/postcss-scope-pseudo-class": "^3.0.1", + "@csstools/postcss-stepped-value-functions": "^3.0.10", + "@csstools/postcss-text-decoration-shorthand": "^3.0.7", + "@csstools/postcss-trigonometric-functions": "^3.0.10", + "@csstools/postcss-unset-value": "^3.0.1", + "autoprefixer": "^10.4.19", + "browserslist": "^4.23.1", + "css-blank-pseudo": "^6.0.2", + "css-has-pseudo": "^6.0.5", + "css-prefers-color-scheme": "^9.0.1", + "cssdb": "^8.1.0", + "postcss-attribute-case-insensitive": "^6.0.3", "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^6.0.2", - "postcss-color-hex-alpha": "^9.0.2", - "postcss-color-rebeccapurple": "^9.0.1", - "postcss-custom-media": "^10.0.2", - "postcss-custom-properties": "^13.3.2", - "postcss-custom-selectors": "^7.1.6", - "postcss-dir-pseudo-class": "^8.0.0", - "postcss-double-position-gradients": "^5.0.2", - "postcss-focus-visible": "^9.0.0", - "postcss-focus-within": "^8.0.0", + "postcss-color-functional-notation": "^6.0.14", + "postcss-color-hex-alpha": "^9.0.4", + "postcss-color-rebeccapurple": "^9.0.3", + "postcss-custom-media": "^10.0.8", + "postcss-custom-properties": "^13.3.12", + "postcss-custom-selectors": "^7.1.12", + "postcss-dir-pseudo-class": "^8.0.1", + "postcss-double-position-gradients": "^5.0.7", + "postcss-focus-visible": "^9.0.1", + "postcss-focus-within": "^8.0.1", "postcss-font-variant": "^5.0.0", - "postcss-gap-properties": "^5.0.0", - "postcss-image-set-function": "^6.0.1", - "postcss-lab-function": "^6.0.7", - "postcss-logical": "^7.0.0", - "postcss-nesting": "^12.0.1", + "postcss-gap-properties": "^5.0.1", + "postcss-image-set-function": "^6.0.3", + "postcss-lab-function": "^6.0.19", + "postcss-logical": "^7.0.1", + "postcss-nesting": "^12.1.5", "postcss-opacity-percentage": "^2.0.0", - "postcss-overflow-shorthand": "^5.0.0", + "postcss-overflow-shorthand": "^5.0.1", "postcss-page-break": "^3.0.4", - "postcss-place": "^9.0.0", - "postcss-pseudo-class-any-link": "^9.0.0", + "postcss-place": "^9.0.1", + "postcss-pseudo-class-any-link": "^9.0.2", "postcss-replace-overflow-wrap": "^4.0.0", - "postcss-selector-not": "^7.0.1", - "postcss-value-parser": "^4.2.0" + "postcss-selector-not": "^7.0.2" }, "engines": { "node": "^14 || ^16 || >=18" @@ -13585,9 +14527,9 @@ } }, "node_modules/postcss-pseudo-class-any-link": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-9.0.1.tgz", - "integrity": "sha512-cKYGGZ9yzUZi+dZd7XT2M8iSDfo+T2Ctbpiizf89uBTBfIpZpjvTavzIJXpCReMVXSKROqzpxClNu6fz4DHM0Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-9.0.2.tgz", + "integrity": "sha512-HFSsxIqQ9nA27ahyfH37cRWGk3SYyQLpk0LiWw/UGMV4VKT5YG2ONee4Pz/oFesnK0dn2AjcyequDbIjKJgB0g==", "funding": [ { "type": "github", @@ -13598,6 +14540,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^6.0.13" }, @@ -13609,29 +14552,31 @@ } }, "node_modules/postcss-reduce-initial": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.0.2.tgz", - "integrity": "sha512-YGKalhNlCLcjcLvjU5nF8FyeCTkCO5UtvJEt0hrPZVCTtRLSOH4z00T1UntQPj4dUmIYZgMj8qK77JbSX95hSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.2.tgz", + "integrity": "sha512-pOnu9zqQww7dEKf62Nuju6JgsW2V0KRNBHxeKohU+JkHd/GAH5uvoObqFLqkeB2n20mr6yrlWDvo5UBU5GnkfA==", + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.3", "caniuse-api": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-reduce-transforms": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.1.tgz", - "integrity": "sha512-fUbV81OkUe75JM+VYO1gr/IoA2b/dRiH6HvMwhrIBSUrxq3jNZQZitSnugcTLDi1KkQh1eR/zi+iyxviUNBkcQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.0.tgz", + "integrity": "sha512-pnt1HKKZ07/idH8cpATX/ujMbtOGhUfE+m8gbqwJE05aTaNw8gbo34a2e3if0xc0dlu75sUOiqvwCGY3fzOHew==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" @@ -13641,32 +14586,41 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "license": "MIT", "peerDependencies": { "postcss": "^8.0.3" } }, "node_modules/postcss-selector-not": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-7.0.1.tgz", - "integrity": "sha512-1zT5C27b/zeJhchN7fP0kBr16Cc61mu7Si9uWWLoA3Px/D9tIJPKchJCkUH3tPO5D0pCFmGeApAv8XpXBQJ8SQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-7.0.2.tgz", + "integrity": "sha512-/SSxf/90Obye49VZIfc0ls4H0P6i6V1iHv0pzZH8SdgvZOPFkF37ef1r5cyWcMflJSFJ5bfuoluTnFnBBFiuSA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^6.0.13" }, "engines": { "node": "^14 || ^16 || >=18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13676,29 +14630,31 @@ } }, "node_modules/postcss-svgo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.2.tgz", - "integrity": "sha512-IH5R9SjkTkh0kfFOQDImyy1+mTCb+E830+9SV1O+AaDcoHTvfsvt6WwJeo7KwcHbFnevZVCsXhDmjFiGVuwqFQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.0.1.tgz", + "integrity": "sha512-0WBUlSL4lhD9rA5k1e5D8EN5wCEyZD6HJk0jIvRxl+FDVOMlJ7DePHYWGGVc5QRqrJ3/06FTXM0bxjmJpmTPSA==", + "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", - "svgo": "^3.2.0" + "svgo": "^3.3.2" }, "engines": { - "node": "^14 || ^16 || >= 18" + "node": "^18.12.0 || ^20.9.0 || >= 18" }, "peerDependencies": { "postcss": "^8.4.31" } }, "node_modules/postcss-unique-selectors": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.2.tgz", - "integrity": "sha512-8IZGQ94nechdG7Y9Sh9FlIY2b4uS8/k8kdKRX040XHsS3B6d1HrJAkXrBSsSu4SuARruSsUjW3nlSw8BHkaAYQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.2.tgz", + "integrity": "sha512-CjSam+7Vf8cflJQsHrMS0P2hmy9u0+n/P001kb5eAszLmhjMqrt/i5AqQuNFihhViwDvEAezqTmXqaYXL2ugMw==", + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.15" + "postcss-selector-parser": "^6.1.1" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" @@ -13708,6 +14664,7 @@ "version": "10.1.3", "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-10.1.3.tgz", "integrity": "sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==", + "license": "MIT", "dependencies": { "make-dir": "~3.1.0", "mime": "~2.5.2", @@ -13725,6 +14682,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -13734,6 +14692,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13750,6 +14709,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", "integrity": "sha512-PhmXi5XmoyKw1Un4E+opM2KcsJInDvKyuOumcjjw3waw86ZNjHwVUOOWLc4bCzLdcKNaWBH9e99sbWzDQsVaYg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -13784,6 +14744,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz", "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==", + "license": "MIT", "dependencies": { "lodash": "^4.17.20", "renderkid": "^2.0.4" @@ -13793,6 +14754,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", + "license": "MIT", "engines": { "node": ">=4" } @@ -13819,6 +14781,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", @@ -13834,7 +14797,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", - "dev": true + "optional": true }, "node_modules/prr": { "version": "1.0.1", @@ -13844,13 +14807,14 @@ "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "license": "ISC" }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true + "optional": true }, "node_modules/public-encrypt": { "version": "4.0.3", @@ -13924,6 +14888,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", "integrity": "sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==", + "license": "MIT", "dependencies": { "object-assign": "^4.1.0", "strict-uri-encode": "^1.0.0" @@ -13944,7 +14909,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true + "optional": true }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -14034,13 +14999,13 @@ } }, "node_modules/rc9": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.1.tgz", - "integrity": "sha512-lNeOl38Ws0eNxpO3+wD1I9rkHGQyj1NU1jlzv4go2CtEnEQEUfqnIvZG7W+bC/aXdJ27n5x/yUjb6RoT9tko+Q==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", "dependencies": { - "defu": "^6.1.2", - "destr": "^2.0.0", - "flat": "^5.0.2" + "defu": "^6.1.4", + "destr": "^2.0.3" } }, "node_modules/rc9/node_modules/defu": { @@ -14092,12 +15057,14 @@ "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", + "license": "MIT", "dependencies": { "regenerate": "^1.4.2" }, @@ -14114,6 +15081,7 @@ "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.4" } @@ -14131,13 +15099,15 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", - "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "set-function-name": "^2.0.0" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -14150,6 +15120,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "license": "MIT", "dependencies": { "@babel/regjsgen": "^0.8.0", "regenerate": "^1.4.2", @@ -14166,6 +15137,7 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "license": "BSD-2-Clause", "dependencies": { "jsesc": "~0.5.0" }, @@ -14185,6 +15157,7 @@ "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -14199,6 +15172,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz", "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==", + "license": "MIT", "dependencies": { "css-select": "^4.1.3", "dom-converter": "^0.2.0", @@ -14211,6 +15185,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -14219,6 +15194,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", @@ -14234,6 +15210,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", @@ -14247,6 +15224,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.2.0" }, @@ -14261,6 +15239,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", @@ -14274,6 +15253,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } @@ -14282,6 +15262,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", "dependencies": { "ansi-regex": "^2.0.0" }, @@ -14309,7 +15290,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", - "dev": true, + "optional": true, "dependencies": { "throttleit": "^1.0.0" } @@ -14318,6 +15299,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -14343,6 +15325,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -14373,6 +15364,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", "engines": { "node": ">= 4" } @@ -14390,7 +15382,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", - "dev": true + "optional": true }, "node_modules/rimraf": { "version": "2.7.1", @@ -14501,12 +15493,13 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", - "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", - "get-intrinsic": "^1.2.2", + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -14520,7 +15513,8 @@ "node_modules/safe-array-concat/node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" }, "node_modules/safe-buffer": { "version": "5.1.2", @@ -14536,12 +15530,13 @@ } }, "node_modules/safe-regex-test": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.2.tgz", - "integrity": "sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", - "get-intrinsic": "^1.2.2", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" }, "engines": { @@ -14683,22 +15678,25 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } }, "node_modules/serve-placeholder": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/serve-placeholder/-/serve-placeholder-2.0.1.tgz", - "integrity": "sha512-rUzLlXk4uPFnbEaIz3SW8VISTxMuONas88nYWjAWaM2W9VDbt9tyFOr3lq8RhVOFrT3XISoBw8vni5una8qMnQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/serve-placeholder/-/serve-placeholder-2.0.2.tgz", + "integrity": "sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==", + "license": "MIT", "dependencies": { - "defu": "^6.0.0" + "defu": "^6.1.4" } }, "node_modules/serve-placeholder/node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" }, "node_modules/serve-static": { "version": "1.15.0", @@ -14717,31 +15715,36 @@ "node_modules/server-destroy": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", - "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==" + "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", + "license": "ISC" }, "node_modules/set-function-length": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", - "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", "dependencies": { - "define-data-property": "^1.1.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.2", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/set-function-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", - "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -14837,6 +15840,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -14863,6 +15867,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", @@ -14884,7 +15889,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, + "optional": true, "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -15076,6 +16081,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", + "license": "MIT", "dependencies": { "is-plain-obj": "^1.0.0" }, @@ -15087,6 +16093,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -15110,9 +16117,10 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -15160,7 +16168,7 @@ "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dev": true, + "optional": true, "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -15185,6 +16193,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", "dependencies": { "minipass": "^3.1.1" }, @@ -15196,12 +16205,14 @@ "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", - "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "license": "MIT" }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", "engines": { "node": "*" } @@ -15209,7 +16220,8 @@ "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" }, "node_modules/static-extend": { "version": "0.1.2", @@ -15298,6 +16310,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -15339,13 +16352,15 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", - "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -15355,26 +16370,31 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", - "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", - "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15416,6 +16436,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/style-resources-loader/-/style-resources-loader-1.5.0.tgz", "integrity": "sha512-fIfyvQ+uvXaCBGGAgfh+9v46ARQB1AWdaop2RpQw0PBVuROsTBqGvx8dj0kxwjGOAyq3vepe4AOK3M6+Q/q2jw==", + "license": "MIT", "dependencies": { "glob": "^7.2.0", "loader-utils": "^2.0.0", @@ -15433,6 +16454,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -15442,6 +16464,8 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -15461,6 +16485,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -15472,6 +16497,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -15485,6 +16511,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -15496,6 +16523,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.5", "ajv": "^6.12.4", @@ -15510,20 +16538,22 @@ } }, "node_modules/style-resources-loader/node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" }, "node_modules/stylehacks": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.0.2.tgz", - "integrity": "sha512-00zvJGnCu64EpMjX8b5iCZ3us2Ptyw8+toEkb92VdmkEaRaSGBNKAoK6aWZckhXxmQP8zWiTaFaiMGIU8Ve8sg==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.3.tgz", + "integrity": "sha512-4DqtecvI/Nd+2BCvW9YEF6lhBN5UM50IJ1R3rnEAhBwbCKf4VehRf+uqvnVArnBayjYD/WtT3g0G/HSRxWfTRg==", + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", - "postcss-selector-parser": "^6.0.15" + "browserslist": "^4.23.3", + "postcss-selector-parser": "^6.1.1" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { "postcss": "^8.4.31" @@ -15634,9 +16664,10 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==" }, "node_modules/svgo": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz", - "integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "license": "MIT", "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", @@ -15661,6 +16692,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", "engines": { "node": ">= 10" } @@ -15723,23 +16755,6 @@ "node": ">=10" } }, - "node_modules/tailwindcss/node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, "node_modules/tapable": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", @@ -15749,9 +16764,10 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -15768,6 +16784,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", "engines": { "node": ">=8" } @@ -15776,6 +16793,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" }, @@ -15786,7 +16804,8 @@ "node_modules/tar/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, "node_modules/terser": { "version": "4.8.1", @@ -15808,6 +16827,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz", "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==", + "license": "MIT", "dependencies": { "cacache": "^15.0.5", "find-cache-dir": "^3.3.1", @@ -15831,9 +16851,10 @@ } }, "node_modules/terser-webpack-plugin/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -15845,6 +16866,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -15859,14 +16881,16 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } }, "node_modules/terser-webpack-plugin/node_modules/terser": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", - "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "version": "5.31.6", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", + "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", + "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -15905,6 +16929,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/thread-loader/-/thread-loader-3.0.4.tgz", "integrity": "sha512-ByaL2TPb+m6yArpqQUZvP+5S1mZtXsEP7nWKKlAUTm7fCml8kB5s1uI3+eHRP2bk5mVYfRSBI7FFf+tWEyLZwA==", + "license": "MIT", "dependencies": { "json-parse-better-errors": "^1.0.2", "loader-runner": "^4.1.0", @@ -15927,6 +16952,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -15938,6 +16964,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -15951,7 +16978,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", - "dev": true, + "optional": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -15974,6 +17001,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/time-fix-plugin/-/time-fix-plugin-2.0.7.tgz", "integrity": "sha512-uVFet1LQToeUX0rTcSiYVYVoGuBpc8gP/2jnlUzuHMHe+gux6XLsNzxLUweabMwiUj5ejhoIMsUI55nVSEa/Vw==", + "license": "MIT", "peerDependencies": { "webpack": ">=4.0.0" } @@ -16077,6 +17105,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", "engines": { "node": ">=6" } @@ -16085,7 +17114,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", - "dev": true, + "optional": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -16100,7 +17129,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, + "optional": true, "engines": { "node": ">= 4.0.0" } @@ -16125,6 +17154,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", "integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==", + "license": "MIT", "engines": { "node": ">=6" }, @@ -16148,7 +17178,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, + "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -16160,7 +17190,7 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "dev": true + "optional": true }, "node_modules/type": { "version": "1.2.0", @@ -16177,27 +17207,30 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -16207,15 +17240,17 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -16225,13 +17260,20 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -16243,9 +17285,9 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "node_modules/ua-parser-js": { - "version": "1.0.37", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", - "integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==", + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", + "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", "funding": [ { "type": "opencollective", @@ -16260,30 +17302,22 @@ "url": "https://github.com/sponsors/faisalman" } ], + "license": "MIT", "engines": { "node": "*" } }, "node_modules/ufo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", - "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==" - }, - "node_modules/uglify-js": { - "version": "3.17.4", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", - "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", + "license": "MIT" }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", @@ -16302,12 +17336,17 @@ "node_modules/unfetch": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz", - "integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==" + "integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==", + "license": "MIT", + "workspaces": [ + "./packages/isomorphic-unfetch" + ] }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "license": "MIT", "engines": { "node": ">=4" } @@ -16316,6 +17355,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" @@ -16328,6 +17368,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "license": "MIT", "engines": { "node": ">=4" } @@ -16336,6 +17377,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "license": "MIT", "engines": { "node": ">=4" } @@ -16442,7 +17484,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, + "optional": true, "engines": { "node": ">=8" } @@ -16457,9 +17499,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "funding": [ { "type": "opencollective", @@ -16474,9 +17516,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -16485,11 +17528,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/upper-case": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", - "integrity": "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==" - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -16517,6 +17555,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", + "license": "MIT", "dependencies": { "loader-utils": "^2.0.0", "mime-types": "^2.1.27", @@ -16543,6 +17582,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -16554,6 +17594,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -16567,7 +17608,7 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, + "optional": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -16603,6 +17644,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz", "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==", + "license": "MIT", "dependencies": { "define-properties": "^1.1.2", "object.getownpropertydescriptors": "^2.0.3" @@ -16616,7 +17658,8 @@ "node_modules/utila": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==" + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -16630,7 +17673,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, + "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -16655,10 +17698,10 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "dev": true, "engines": [ "node >=0.6.0" ], + "optional": true, "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -16669,7 +17712,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "optional": true }, "node_modules/vm-browserify": { "version": "1.1.2", @@ -16689,17 +17732,20 @@ "node_modules/vue-client-only": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/vue-client-only/-/vue-client-only-2.1.0.tgz", - "integrity": "sha512-vKl1skEKn8EK9f8P2ZzhRnuaRHLHrlt1sbRmazlvsx6EiC3A8oWF8YCBrMJzoN+W3OnElwIGbVjsx6/xelY1AA==" + "integrity": "sha512-vKl1skEKn8EK9f8P2ZzhRnuaRHLHrlt1sbRmazlvsx6EiC3A8oWF8YCBrMJzoN+W3OnElwIGbVjsx6/xelY1AA==", + "license": "MIT" }, "node_modules/vue-hot-reload-api": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz", - "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==" + "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", + "license": "MIT" }, "node_modules/vue-loader": { "version": "15.11.1", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.11.1.tgz", "integrity": "sha512-0iw4VchYLePqJfJu9s62ACWUXeSqM30SQqlIftbYWM3C+jpPcEHKSPUZBLjSF9au4HTHQ/naF6OGnO3Q/qGR3Q==", + "license": "MIT", "dependencies": { "@vue/component-compiler-utils": "^3.1.0", "hash-sum": "^1.0.2", @@ -16726,12 +17772,14 @@ "node_modules/vue-loader/node_modules/hash-sum": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz", - "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==" + "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==", + "license": "MIT" }, "node_modules/vue-meta": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/vue-meta/-/vue-meta-2.4.0.tgz", "integrity": "sha512-XEeZUmlVeODclAjCNpWDnjgw+t3WA6gdzs6ENoIAgwO1J1d5p1tezDhtteLUFwcaQaTtayRrsx7GL6oXp/m2Jw==", + "license": "MIT", "dependencies": { "deepmerge": "^4.2.2" } @@ -16739,7 +17787,8 @@ "node_modules/vue-no-ssr": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz", - "integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==" + "integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==", + "license": "MIT" }, "node_modules/vue-resize-sensor": { "version": "2.0.0", @@ -16749,12 +17798,14 @@ "node_modules/vue-router": { "version": "3.6.5", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.6.5.tgz", - "integrity": "sha512-VYXZQLtjuvKxxcshuRAwjHnciqZVoXAjTjcqBTz4rKc8qih9g9pI3hbDjmqXaHdgL3v8pV6P8Z335XvHzESxLQ==" + "integrity": "sha512-VYXZQLtjuvKxxcshuRAwjHnciqZVoXAjTjcqBTz4rKc8qih9g9pI3hbDjmqXaHdgL3v8pV6P8Z335XvHzESxLQ==", + "license": "MIT" }, "node_modules/vue-server-renderer": { "version": "2.7.16", "resolved": "https://registry.npmjs.org/vue-server-renderer/-/vue-server-renderer-2.7.16.tgz", "integrity": "sha512-U7GgR4rYmHmbs3Z2gqsasfk7JNuTsy/xrR5EMMGRLkjN8+ryDlqQq6Uu3DcmbCATAei814YOxyl0eq2HNqgXyQ==", + "license": "MIT", "dependencies": { "chalk": "^4.1.2", "hash-sum": "^2.0.0", @@ -16770,6 +17821,7 @@ "version": "0.5.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -16778,6 +17830,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz", "integrity": "sha512-sFuh0xfbtpRlKfm39ss/ikqs9AbKCoXZBpHeVZ8Tx650o0k0q/YCM7FRvigtxpACezfq6af+a7JeqVTWvncqDg==", + "license": "MIT", "dependencies": { "hash-sum": "^1.0.2", "loader-utils": "^1.0.2" @@ -16786,7 +17839,8 @@ "node_modules/vue-style-loader/node_modules/hash-sum": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz", - "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==" + "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==", + "license": "MIT" }, "node_modules/vue-template-compiler": { "version": "2.7.16", @@ -16800,7 +17854,8 @@ "node_modules/vue-template-es2015-compiler": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz", - "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==" + "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==", + "license": "MIT" }, "node_modules/vue-toastification": { "version": "1.7.14", @@ -16822,14 +17877,16 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.2.tgz", "integrity": "sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==", + "license": "MIT", "peerDependencies": { "vue": "^2.0.0" } }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -17161,9 +18218,10 @@ } }, "node_modules/webpack-bundle-analyzer": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", - "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "license": "MIT", "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", @@ -17173,7 +18231,6 @@ "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", - "is-plain-object": "^5.0.0", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", @@ -17187,9 +18244,10 @@ } }, "node_modules/webpack-bundle-analyzer/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -17201,6 +18259,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", "engines": { "node": ">= 10" } @@ -17209,6 +18268,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -17216,18 +18276,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-bundle-analyzer/node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", "engines": { "node": ">=8.3.0" }, @@ -17245,9 +18298,10 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", + "license": "MIT", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", @@ -17267,14 +18321,15 @@ } }, "node_modules/webpack-dev-middleware/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -17285,6 +18340,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -17295,12 +18351,26 @@ "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/webpack-dev-middleware/node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -17316,9 +18386,10 @@ } }, "node_modules/webpack-hot-middleware": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.26.0.tgz", - "integrity": "sha512-okzjec5sAEy4t+7rzdT8eRyxsk0FDSmBPN2KwX4Qd+6+oQCfe5Ve07+u7cJvofgB+B4w5/4dO4Pz0jhhHyyPLQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.26.1.tgz", + "integrity": "sha512-khZGfAeJx6I8K9zKohEWWYN6KDlVw2DHownoe+6Vtwj1LP9WFgegXnVMSkZ/dBEBtXFwrkkydsaPFlB7f8wU2A==", + "license": "MIT", "dependencies": { "ansi-html-community": "0.0.8", "html-entities": "^2.1.0", @@ -17329,6 +18400,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "license": "MIT", "engines": { "node": ">=6" } @@ -17718,9 +18790,10 @@ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" }, "node_modules/webpackbar": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.0.tgz", - "integrity": "sha512-RdB0RskzOaix1VFMnBXSkKMbUgvZliRqgoNp0gCnG6iUe9RS9sf018AJ/1h5NAeh+ttwXkXjXKC6NdjE/OOcaA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.1.tgz", + "integrity": "sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==", + "license": "MIT", "dependencies": { "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -17728,7 +18801,7 @@ "figures": "^3.2.0", "markdown-table": "^2.0.0", "pretty-time": "^1.1.0", - "std-env": "^3.6.0", + "std-env": "^3.7.0", "wrap-ansi": "^7.0.0" }, "engines": { @@ -17742,6 +18815,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } @@ -17773,6 +18847,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "license": "MIT", "dependencies": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -17785,15 +18860,16 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -17806,6 +18882,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", "dependencies": { "string-width": "^4.0.0" }, @@ -17916,6 +18993,7 @@ "version": "2.4.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "license": "ISC", "dependencies": { "graceful-fs": "^4.1.11", "imurmurhash": "^0.1.4", @@ -17926,6 +19004,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/write-json-file/-/write-json-file-2.3.0.tgz", "integrity": "sha512-84+F0igFp2dPD6UpAQjOUX3CdKUOqUzn6oE9sDBNzUXINR5VceJ1rauZltqQB/bcYsx3EpKys4C7/PivKUAiWQ==", + "license": "MIT", "dependencies": { "detect-indent": "^5.0.0", "graceful-fs": "^4.1.2", @@ -17942,6 +19021,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -17950,6 +19030,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "license": "MIT", "dependencies": { "pify": "^3.0.0" }, @@ -17961,6 +19042,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", "engines": { "node": ">=4" } @@ -17969,6 +19051,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", "integrity": "sha512-/dPCrG1s3ePpWm6yBbxZq5Be1dXGLyLn9Z791chDC3NFrpkVbWGzkBwPN1knaciexFXgRJ7hzdnwZ4stHSDmjg==", + "license": "MIT", "dependencies": { "is-plain-obj": "^1.0.0" }, @@ -18016,6 +19099,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz", "integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==", + "license": "MIT", "dependencies": { "cuint": "^0.2.2" } @@ -18029,6 +19113,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", "engines": { "node": ">= 6" } @@ -18037,7 +19122,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, + "optional": true, "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" @@ -18047,6 +19132,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", "engines": { "node": ">=10" }, diff --git a/client/package.json b/client/package.json index e33e4ac890..59ce6261bd 100644 --- a/client/package.json +++ b/client/package.json @@ -27,7 +27,7 @@ "fast-average-color": "^9.4.0", "hls.js": "^1.5.7", "libarchive.js": "^1.3.0", - "nuxt": "^2.17.3", + "nuxt": "^2.18.1", "nuxt-socket-io": "^1.1.18", "trix": "^1.3.1", "v-click-outside": "^3.1.2", From 3f93b93d9e9e25c0c75476c5584cc465cf8f9fea Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 4 Sep 2024 12:48:10 +0300 Subject: [PATCH 067/539] Add db migration management infratructure --- package-lock.json | 650 +++++++++++++----- package.json | 2 + server/Database.js | 12 + server/managers/MigrationManager.js | 199 ++++++ server/migrations/changelog.md | 7 + server/migrations/readme.md | 46 ++ test/server/managers/MigrationManager.test.js | 484 +++++++++++++ .../managers/migrations/v1.0.0-migration.js | 9 + .../managers/migrations/v1.1.0-migration.js | 9 + .../managers/migrations/v1.10.0-migration.js | 9 + .../managers/migrations/v1.2.0-migration.js | 9 + .../migrations/v0.0.1-migration_example.js | 42 ++ .../v0.0.1-migration_example.test.js | 53 ++ 13 files changed, 1372 insertions(+), 159 deletions(-) create mode 100644 server/managers/MigrationManager.js create mode 100644 server/migrations/changelog.md create mode 100644 server/migrations/readme.md create mode 100644 test/server/managers/MigrationManager.test.js create mode 100644 test/server/managers/migrations/v1.0.0-migration.js create mode 100644 test/server/managers/migrations/v1.1.0-migration.js create mode 100644 test/server/managers/migrations/v1.10.0-migration.js create mode 100644 test/server/managers/migrations/v1.2.0-migration.js create mode 100644 test/server/migrations/v0.0.1-migration_example.js create mode 100644 test/server/migrations/v0.0.1-migration_example.test.js diff --git a/package-lock.json b/package-lock.json index eada191873..4dd6d347a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,12 @@ "p-throttle": "^4.1.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", + "semver": "^7.6.3", "sequelize": "^6.35.2", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", "ssrf-req-filter": "^1.1.0", + "umzug": "^3.8.1", "xml2js": "^0.5.0" }, "bin": { @@ -173,6 +175,15 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.23.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", @@ -213,6 +224,15 @@ "yallist": "^3.0.2" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -586,17 +606,6 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -611,18 +620,36 @@ "node": ">=6" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dependencies": { - "lru-cache": "^6.0.0" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, - "bin": { - "semver": "bin/semver.js" + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": ">=10" + "node": ">= 8" } }, "node_modules/@npmcli/fs": { @@ -635,11 +662,47 @@ "semver": "^7.3.5" } }, - "node_modules/@npmcli/fs/node_modules/lru-cache": { + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/node-core-library": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.7.0.tgz", + "integrity": "sha512-Ff9Cz/YlWu9ce4dmqNBZpA45AEya04XaBFIjV7xTVeEf+y/kTjEasmozqFELXlNG4ROdevss75JrrZ5WgufDkQ==", + "dependencies": { + "ajv": "~8.13.0", + "ajv-draft-04": "~1.0.0", + "ajv-formats": "~3.0.1", + "fs-extra": "~7.0.1", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.5.4" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -647,11 +710,10 @@ "node": ">=10" } }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", - "optional": true, + "node_modules/@rushstack/node-core-library/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -662,18 +724,54 @@ "node": ">=10" } }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "optional": true, + "node_modules/@rushstack/terminal": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.14.0.tgz", + "integrity": "sha512-juTKMAMpTIJKudeFkG5slD8Z/LHwNwGZLtU441l/u82XdTBfsP+LbGKJLCNwP5se+DMCT55GB8x9p6+C4UL7jw==", "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" + "@rushstack/node-core-library": "5.7.0", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/terminal/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@rushstack/terminal/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@rushstack/ts-command-line": { + "version": "4.22.6", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.22.6.tgz", + "integrity": "sha512-QSRqHT/IfoC5nk9zn6+fgyqOPXHME0BfchII9EUPR19pocsNp/xSbeBCbD3PIR2Lg+Q5qk7OFqk1VhWPMdKHJg==", + "dependencies": { + "@rushstack/terminal": "0.14.0", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" } }, "node_modules/@sinonjs/commons": { @@ -734,6 +832,11 @@ "node": ">= 6" } }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==" + }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -869,6 +972,50 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", + "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -953,7 +1100,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -1041,12 +1187,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1642,6 +1787,17 @@ "integrity": "sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==", "dev": true }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1876,11 +2032,38 @@ "node": ">= 0.6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2025,6 +2208,19 @@ } ] }, + "node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -2140,7 +2336,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -2214,6 +2409,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -2364,6 +2570,14 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2436,11 +2650,24 @@ "node": ">=8" } }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -2457,7 +2684,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -2475,7 +2701,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -2576,6 +2801,15 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/istanbul-lib-processinfo": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", @@ -2628,18 +2862,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-report/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-lib-report/node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -2655,21 +2877,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-report/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-lib-report/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2732,6 +2939,11 @@ "node": ">=8" } }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==" + }, "node_modules/jose": { "version": "4.15.4", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", @@ -2771,6 +2983,11 @@ "node": ">=4" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2783,6 +3000,14 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -2804,36 +3029,11 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsonwebtoken/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", @@ -2970,6 +3170,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/make-fetch-happen": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", @@ -3022,6 +3230,14 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -3030,6 +3246,18 @@ "node": ">= 0.6" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -3585,18 +3813,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/node-gyp/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -3627,21 +3843,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", - "optional": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -4061,6 +4262,11 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -4095,7 +4301,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -4115,6 +4320,14 @@ "node": ">=8" } }, + "node_modules/pony-cause": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", + "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -4164,6 +4377,14 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -4178,6 +4399,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -4263,12 +4503,36 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -4292,6 +4556,15 @@ "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", "integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA==" }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4306,6 +4579,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4336,11 +4631,14 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -4456,36 +4754,11 @@ } } }, - "node_modules/sequelize/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/sequelize/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/sequelize/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -4805,8 +5078,7 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "node_modules/sqlite3": { "version": "5.1.6", @@ -4874,6 +5146,14 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4931,6 +5211,17 @@ "node": ">=4" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tar": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", @@ -4982,7 +5273,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -5070,6 +5360,32 @@ "node": ">= 0.8" } }, + "node_modules/umzug": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.8.1.tgz", + "integrity": "sha512-k0HjOc3b/s8vH24BUTvnaFiKhfWI9UQAGpqHDG+3866CGlBTB83Xs5wZ1io1mwYLj/GHvQ34AxKhbpYnWtkRJg==", + "dependencies": { + "@rushstack/ts-command-line": "^4.12.2", + "emittery": "^0.13.0", + "fast-glob": "^3.3.2", + "pony-cause": "^2.1.4", + "type-fest": "^4.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/umzug/node_modules/type-fest": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", + "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -5094,6 +5410,14 @@ "imurmurhash": "^0.1.4" } }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5132,6 +5456,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 9ad9cc9435..c7f3b24cc7 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,12 @@ "p-throttle": "^4.1.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", + "semver": "^7.6.3", "sequelize": "^6.35.2", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", "ssrf-req-filter": "^1.1.0", + "umzug": "^3.8.1", "xml2js": "^0.5.0" }, "devDependencies": { diff --git a/server/Database.js b/server/Database.js index 2e109fa87d..2cf720eb42 100644 --- a/server/Database.js +++ b/server/Database.js @@ -8,6 +8,8 @@ const Logger = require('./Logger') const dbMigration = require('./utils/migrations/dbMigration') const Auth = require('./Auth') +const MigrationManager = require('./managers/MigrationManager') + class Database { constructor() { this.sequelize = null @@ -168,6 +170,16 @@ class Database { throw new Error('Database connection failed') } + if (!this.isNew) { + try { + const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath) + await migrationManager.runMigrations(packageJson.version) + } catch (error) { + Logger.error(`[Database] Failed to run migrations`, error) + throw new Error('Database migration failed') + } + } + await this.buildModels(force) Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', ')) diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js new file mode 100644 index 0000000000..fa607764eb --- /dev/null +++ b/server/managers/MigrationManager.js @@ -0,0 +1,199 @@ +const { Umzug, SequelizeStorage } = require('umzug') +const { Sequelize } = require('sequelize') +const semver = require('semver') +const path = require('path') +const fs = require('../libs/fsExtra') +const Logger = require('../Logger') + +class MigrationManager { + constructor(sequelize, configPath = global.configPath) { + if (!sequelize || !(sequelize instanceof Sequelize)) { + throw new Error('Sequelize instance is required for MigrationManager.') + } + this.sequelize = sequelize + if (!configPath) { + throw new Error('Config path is required for MigrationManager.') + } + this.configPath = configPath + this.migrationsDir = null + this.maxVersion = null + this.databaseVersion = null + this.serverVersion = null + this.umzug = null + } + + async runMigrations(serverVersion) { + await this.init(serverVersion) + + const versionCompare = semver.compare(this.serverVersion, this.databaseVersion) + if (versionCompare == 0) { + Logger.info('[MigrationManager] Database is already up to date.') + return + } + + const migrations = await this.umzug.migrations() + const executedMigrations = (await this.umzug.executed()).map((m) => m.name) + + const migrationDirection = versionCompare == 1 ? 'up' : 'down' + + let migrationsToRun = [] + migrationsToRun = this.findMigrationsToRun(migrations, executedMigrations, migrationDirection) + + // Only proceed with migration if there are migrations to run + if (migrationsToRun.length > 0) { + const originalDbPath = path.join(this.configPath, 'absdatabase.sqlite') + const backupDbPath = path.join(this.configPath, 'absdatabase.backup.sqlite') + try { + Logger.info(`[MigrationManager] Migrating database ${migrationDirection} to version ${this.serverVersion}`) + Logger.info(`[MigrationManager] Migrations to run: ${migrationsToRun.join(', ')}`) + // Create a backup copy of the SQLite database before starting migrations + await fs.copy(originalDbPath, backupDbPath) + Logger.info('Created a backup of the original database.') + + // Run migrations + await this.umzug[migrationDirection]({ migrations: migrationsToRun }) + + // Clean up the backup + await fs.remove(backupDbPath) + + Logger.info('[MigrationManager] Migrations successfully applied to the original database.') + } catch (error) { + Logger.error('[MigrationManager] Migration failed:', error) + + this.sequelize.close() + + // Step 3: If migration fails, save the failed original and restore the backup + const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite') + await fs.move(originalDbPath, failedDbPath, { overwrite: true }) + await fs.move(backupDbPath, originalDbPath, { overwrite: true }) + + Logger.info('[MigrationManager] Restored the original database from the backup.') + Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.') + + process.exit(1) + } + } else { + Logger.info('[MigrationManager] No migrations to run.') + } + } + + async init(serverVersion, umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) { + if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`) + + this.migrationsDir = path.join(this.configPath, 'migrations') + + this.serverVersion = this.extractVersionFromTag(serverVersion) + if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`) + + await this.fetchVersionsFromDatabase() + if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.') + + if (semver.gt(this.serverVersion, this.maxVersion)) { + try { + await this.copyMigrationsToConfigDir() + } catch (error) { + throw new Error('Failed to copy migrations to the config directory.', error) + } + + try { + await this.updateMaxVersion(serverVersion) + } catch (error) { + throw new Error('Failed to update max version in the database.', error) + } + } + + // Step 4: Initialize the Umzug instance + if (!this.umzug) { + // This check is for dependency injection in tests + const cwd = this.migrationsDir + + const parent = new Umzug({ + migrations: { + glob: ['*.js', { cwd }] + }, + context: this.sequelize.getQueryInterface(), + storage: umzugStorage, + logger: Logger.info + }) + + // Sort migrations by version + this.umzug = new Umzug({ + ...parent.options, + migrations: async () => + (await parent.migrations()).sort((a, b) => { + const versionA = this.extractVersionFromTag(a.name) + const versionB = this.extractVersionFromTag(b.name) + return semver.compare(versionA, versionB) + }) + }) + } + } + + async fetchVersionsFromDatabase() { + const [result] = await this.sequelize.query("SELECT json_extract(value, '$.version') AS version, json_extract(value, '$.maxVersion') AS maxVersion FROM settings WHERE key = :key", { + replacements: { key: 'server-settings' }, + type: Sequelize.QueryTypes.SELECT + }) + + if (result) { + try { + this.maxVersion = this.extractVersionFromTag(result.maxVersion) || '0.0.0' + this.databaseVersion = this.extractVersionFromTag(result.version) + } catch (error) { + Logger.error('[MigrationManager] Failed to parse server settings from the database.', error) + } + } + } + + extractVersionFromTag(tag) { + if (!tag) return null + const versionMatch = tag.match(/^v?(\d+\.\d+\.\d+)/) + return versionMatch ? versionMatch[1] : null + } + + async copyMigrationsToConfigDir() { + const migrationsSourceDir = path.join(__dirname, '..', 'migrations') + + await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists + + const files = await fs.readdir(migrationsSourceDir) + await Promise.all( + files + .filter((file) => path.extname(file) === '.js') + .map(async (file) => { + const sourceFile = path.join(migrationsSourceDir, file) + const targetFile = path.join(this.migrationsDir, file) + await fs.copy(sourceFile, targetFile) // Asynchronously copy the files + }) + ) + } + + findMigrationsToRun(migrations, executedMigrations, direction) { + const migrationsToRun = migrations + .filter((migration) => { + const migrationVersion = this.extractVersionFromTag(migration.name) + if (direction === 'up') { + return semver.gt(migrationVersion, this.databaseVersion) && semver.lte(migrationVersion, this.serverVersion) && !executedMigrations.includes(migration.name) + } else { + // A down migration should be run even if the associated up migration wasn't executed before + return semver.lte(migrationVersion, this.databaseVersion) && semver.gt(migrationVersion, this.serverVersion) + } + }) + .map((migration) => migration.name) + if (direction === 'down') { + return migrationsToRun.reverse() + } else { + return migrationsToRun + } + } + + async updateMaxVersion(serverVersion) { + await this.sequelize.query("UPDATE settings SET value = JSON_SET(value, '$.maxVersion', ?) WHERE key = 'server-settings'", { + replacements: [serverVersion], + type: Sequelize.QueryTypes.UPDATE + }) + this.maxVersion = this.serverVersion + } +} + +module.exports = MigrationManager diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md new file mode 100644 index 0000000000..2e3c295af1 --- /dev/null +++ b/server/migrations/changelog.md @@ -0,0 +1,7 @@ +# Migrations Changelog + +Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. + +| Server Version | Migration Script Name | Description | +| -------------- | --------------------- | ----------- | +| | | | diff --git a/server/migrations/readme.md b/server/migrations/readme.md new file mode 100644 index 0000000000..c454950e59 --- /dev/null +++ b/server/migrations/readme.md @@ -0,0 +1,46 @@ +# Database Migrations + +This directory contains all the database migration scripts for the server. + +## What is a migration? + +A migration is a script that changes the structure of the database. This can include creating tables, adding columns, or modifying existing columns. A migration script consists of two parts: an "up" script that applies the changes to the database, and a "down" script that undoes the changes. + +## Guidelines for writing migrations + +When writing a migration, keep the following guidelines in mind: + +- You **_must_** name your migration script according to the following convention: `-.js`. For example, `v2.14.0-create-users-table.js`. + + - `server_version` should be the version of the server that the migration was created for (this should usually be the next server release). + - `migration_name` should be a short description of the changes that the migration makes. + +- The script should export two async functions: `up` and `down`. The `up` function should contain the script that applies the changes to the database, and the `down` function should contain the script that undoes the changes. The `up` and `down` functions should accept a single object parameter with a `context` property that contains a reference to a Sequelize [`QueryInterface`](https://sequelize.org/docs/v6/other-topics/query-interface/) object. A typical migration script might look like this: + + ```javascript + async function up({context: queryInterface}) { + // Upwards migration script + ... + } + + async function down({context: queryInterface}) { + // Downward migration script + ... + } + + module.exports = {up, down} + ``` + +- Always implement both the `up` and `down` functions. +- The `up` and `down` functions should be idempotent (i.e., they should be safe to run multiple times). +- It's your responsibility to make sure that the down migration undoes the changes made by the up migration. +- Log detailed information on every step of the migration. Use `Logger.info()` and `Logger.error()`. +- Test tour migrations thoroughly before committing them. + - write unit tests for your migrations (see `test/server/migrations` for an example) + - you can force a server version change by modifying the `version` field in `package.json` on your dev environment (but don't forget to revert it back before committing) + +## How migrations are run + +Migrations are run automatically when the server starts, when the server detects that the server version has changed. Migrations are always run server version order (from oldest to newest up migrations if the server version increased, and from newest to oldest down migrations if the server version decreased). Only the relevant migrations are run, based on the new and old server versions. + +This means that you can switch between server releases without having to worry about running migrations manually. The server will automatically apply the necessary migrations when it starts. diff --git a/test/server/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js new file mode 100644 index 0000000000..49689f6353 --- /dev/null +++ b/test/server/managers/MigrationManager.test.js @@ -0,0 +1,484 @@ +const { expect, config } = require('chai') +const sinon = require('sinon') +const { Sequelize } = require('sequelize') +const fs = require('../../../server/libs/fsExtra') +const Logger = require('../../../server/Logger') +const MigrationManager = require('../../../server/managers/MigrationManager') +const { Umzug, memoryStorage } = require('umzug') +const path = require('path') + +describe('MigrationManager', () => { + let sequelizeStub + let umzugStub + let migrationManager + let loggerInfoStub + let loggerErrorStub + let fsCopyStub + let fsMoveStub + let fsRemoveStub + let fsEnsureDirStub + let fsPathExistsStub + let processExitStub + let configPath = 'path/to/config' + + const serverVersion = '1.2.0' + + beforeEach(() => { + sequelizeStub = sinon.createStubInstance(Sequelize) + umzugStub = { + migrations: sinon.stub(), + executed: sinon.stub(), + up: sinon.stub(), + down: sinon.stub() + } + sequelizeStub.getQueryInterface.returns({}) + migrationManager = new MigrationManager(sequelizeStub, configPath) + migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves() + migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves() + migrationManager.updateMaxVersion = sinon.stub().resolves() + migrationManager.umzug = umzugStub + loggerInfoStub = sinon.stub(Logger, 'info') + loggerErrorStub = sinon.stub(Logger, 'error') + fsCopyStub = sinon.stub(fs, 'copy').resolves() + fsMoveStub = sinon.stub(fs, 'move').resolves() + fsRemoveStub = sinon.stub(fs, 'remove').resolves() + fsEnsureDirStub = sinon.stub(fs, 'ensureDir').resolves() + fsPathExistsStub = sinon.stub(fs, 'pathExists').resolves(true) + processExitStub = sinon.stub(process, 'exit') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('runMigrations', () => { + it('should run up migrations successfully', async () => { + // Arrange + migrationManager.databaseVersion = '1.1.0' + migrationManager.maxVersion = '1.1.0' + + umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) + umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }]) + + // Act + await migrationManager.runMigrations('1.2.0') + + // Assert + expect(migrationManager.fetchVersionsFromDatabase.calledOnce).to.be.true + expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true + expect(migrationManager.updateMaxVersion.calledOnce).to.be.true + expect(umzugStub.up.calledOnce).to.be.true + expect(umzugStub.up.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'] })).to.be.true + expect(fsCopyStub.calledOnce).to.be.true + expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true + expect(fsRemoveStub.calledOnce).to.be.true + expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true + }) + + it('should run down migrations successfully', async () => { + // Arrange + migrationManager.databaseVersion = '1.2.0' + migrationManager.maxVersion = '1.2.0' + + umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) + umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) + + // Act + await migrationManager.runMigrations('1.1.0') + + // Assert + expect(migrationManager.fetchVersionsFromDatabase.calledOnce).to.be.true + expect(migrationManager.copyMigrationsToConfigDir.called).to.be.false + expect(migrationManager.updateMaxVersion.called).to.be.false + expect(umzugStub.down.calledOnce).to.be.true + expect(umzugStub.down.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'] })).to.be.true + expect(fsCopyStub.calledOnce).to.be.true + expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true + expect(fsRemoveStub.calledOnce).to.be.true + expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true + }) + + it('should log that no migrations are needed if serverVersion equals databaseVersion', async () => { + // Arrange + migrationManager.serverVersion = '1.2.0' + migrationManager.databaseVersion = '1.2.0' + migrationManager.maxVersion = '1.2.0' + + // Act + await migrationManager.runMigrations(serverVersion) + + // Assert + expect(umzugStub.up.called).to.be.false + expect(loggerInfoStub.calledWith(sinon.match('Database is already up to date.'))).to.be.true + }) + + it('should handle migration failure and restore the original database', async () => { + // Arrange + migrationManager.serverVersion = '1.2.0' + migrationManager.databaseVersion = '1.1.0' + migrationManager.maxVersion = '1.1.0' + + umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }]) + umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }]) + umzugStub.up.rejects(new Error('Migration failed')) + + const originalDbPath = path.join(configPath, 'absdatabase.sqlite') + const backupDbPath = path.join(configPath, 'absdatabase.backup.sqlite') + + // Act + await migrationManager.runMigrations(serverVersion) + + // Assert + expect(umzugStub.up.calledOnce).to.be.true + expect(loggerErrorStub.calledWith(sinon.match('Migration failed'))).to.be.true + expect(fsMoveStub.calledWith(originalDbPath, sinon.match('absdatabase.failed.sqlite'), { overwrite: true })).to.be.true + expect(fsMoveStub.calledWith(backupDbPath, originalDbPath, { overwrite: true })).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('Restored the original database'))).to.be.true + expect(processExitStub.calledOnce).to.be.true + }) + }) + + describe('init', () => { + it('should throw error if serverVersion is not provided', async () => { + // Act + try { + const result = await migrationManager.init() + expect.fail('Expected init to throw an error, but it did not.') + } catch (error) { + expect(error.message).to.equal('Invalid server version: undefined. Expected a version tag like v1.2.3.') + } + }) + + it('should initialize the MigrationManager', async () => { + // arrange + migrationManager.databaseVersion = '1.1.0' + migrationManager.maxVersion = '1.1.0' + migrationManager.umzug = null + migrationManager.configPath = __dirname + + // Act + await migrationManager.init(serverVersion, memoryStorage()) + + // Assert + expect(migrationManager.serverVersion).to.equal('1.2.0') + expect(migrationManager.sequelize).to.equal(sequelizeStub) + expect(migrationManager.umzug).to.be.an.instanceOf(Umzug) + expect((await migrationManager.umzug.migrations()).map((m) => m.name)).to.deep.equal(['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js']) + }) + }) + + describe('fetchVersionsFromDatabase', () => { + it('should fetch versions from a real database', async () => { + // Arrange + const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + const serverSettings = { version: 'v1.1.0', maxVersion: 'v1.1.0' } + // Create a settings table with a single row + await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') + await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } }) + const migrationManager = new MigrationManager(sequelize, configPath) + + // Act + await migrationManager.fetchVersionsFromDatabase() + + // Assert + expect(migrationManager.maxVersion).to.equal('1.1.0') + expect(migrationManager.databaseVersion).to.equal('1.1.0') + }) + + it('should set versions to null if no result is returned from the database', async () => { + // Arrange + const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') + const migrationManager = new MigrationManager(sequelize, configPath) + + // Act + await migrationManager.fetchVersionsFromDatabase() + + // Assert + expect(migrationManager.maxVersion).to.be.null + expect(migrationManager.databaseVersion).to.be.null + }) + + it('should return a default maxVersion if no maxVersion is set in the database', async () => { + // Arrange + const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + const serverSettings = { version: 'v1.1.0' } + // Create a settings table with a single row + await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') + await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } }) + const migrationManager = new MigrationManager(sequelize, configPath) + + // Act + await migrationManager.fetchVersionsFromDatabase() + + // Assert + expect(migrationManager.maxVersion).to.equal('0.0.0') + expect(migrationManager.databaseVersion).to.equal('1.1.0') + }) + + it('should throw an error if the database query fails', async () => { + // Arrange + const sequelizeStub = sinon.createStubInstance(Sequelize) + sequelizeStub.query.rejects(new Error('Database query failed')) + const migrationManager = new MigrationManager(sequelizeStub, configPath) + + // Act + try { + await migrationManager.fetchVersionsFromDatabase() + expect.fail('Expected fetchVersionsFromDatabase to throw an error, but it did not.') + } catch (error) { + // Assert + expect(error.message).to.equal('Database query failed') + } + }) + }) + + describe('updateMaxVersion', () => { + it('should update the maxVersion in the database', async () => { + // Arrange + const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + const serverSettings = { version: 'v1.1.0', maxVersion: 'v1.1.0' } + // Create a settings table with a single row + await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') + await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } }) + const migrationManager = new MigrationManager(sequelize, configPath) + + // Act + await migrationManager.updateMaxVersion('v1.2.0') + + // Assert + const [result] = await sequelize.query("SELECT json_extract(value, '$.maxVersion') AS maxVersion FROM settings WHERE key = :key", { replacements: { key: 'server-settings' }, type: Sequelize.QueryTypes.SELECT }) + expect(result.maxVersion).to.equal('v1.2.0') + }) + }) + + describe('extractVersionFromTag', () => { + it('should return null if tag is not provided', () => { + // Arrange + const migrationManager = new MigrationManager(sequelizeStub, configPath) + + // Act + const result = migrationManager.extractVersionFromTag() + + // Assert + expect(result).to.be.null + }) + + it('should return null if tag does not match the version format', () => { + // Arrange + const migrationManager = new MigrationManager(sequelizeStub, configPath) + const tag = 'invalid-tag' + + // Act + const result = migrationManager.extractVersionFromTag(tag) + + // Assert + expect(result).to.be.null + }) + + it('should extract the version from the tag', () => { + // Arrange + const migrationManager = new MigrationManager(sequelizeStub, configPath) + const tag = 'v1.2.3' + + // Act + const result = migrationManager.extractVersionFromTag(tag) + + // Assert + expect(result).to.equal('1.2.3') + }) + }) + + describe('copyMigrationsToConfigDir', () => { + it('should copy migrations to the config directory', async () => { + // Arrange + const migrationManager = new MigrationManager(sequelizeStub, configPath) + migrationManager.migrationsDir = path.join(configPath, 'migrations') + const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations') + const targetDir = migrationManager.migrationsDir + const files = ['migration1.js', 'migration2.js', 'readme.md'] + + const readdirStub = sinon.stub(fs, 'readdir').resolves(files) + + // Act + await migrationManager.copyMigrationsToConfigDir() + + // Assert + expect(fsEnsureDirStub.calledOnce).to.be.true + expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true + expect(readdirStub.calledOnce).to.be.true + expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true + expect(fsCopyStub.calledTwice).to.be.true + expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true + expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true + }) + + it('should throw an error if copying the migrations fails', async () => { + // Arrange + const migrationManager = new MigrationManager(sequelizeStub, configPath) + migrationManager.migrationsDir = path.join(configPath, 'migrations') + const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations') + const targetDir = migrationManager.migrationsDir + const files = ['migration1.js', 'migration2.js', 'readme.md'] + + const readdirStub = sinon.stub(fs, 'readdir').resolves(files) + fsCopyStub.restore() + fsCopyStub = sinon.stub(fs, 'copy').rejects() + + // Act + try { + // Act + await migrationManager.copyMigrationsToConfigDir() + expect.fail('Expected copyMigrationsToConfigDir to throw an error, but it did not.') + } catch (error) {} + + // Assert + expect(fsEnsureDirStub.calledOnce).to.be.true + expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true + expect(readdirStub.calledOnce).to.be.true + expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true + expect(fsCopyStub.calledTwice).to.be.true + expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true + expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true + }) + }) + + describe('findMigrationsToRun', () => { + it('should return migrations to run when direction is "up"', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = ['v1.0.0-migration.js'] + migrationManager.databaseVersion = '1.0.0' + migrationManager.serverVersion = '1.2.0' + const direction = 'up' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal(['v1.1.0-migration.js', 'v1.2.0-migration.js']) + }) + + it('should return migrations to run when direction is "down"', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = ['v1.2.0-migration.js', 'v1.3.0-migration.js'] + migrationManager.databaseVersion = '1.3.0' + migrationManager.serverVersion = '1.2.0' + const direction = 'down' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal(['v1.3.0-migration.js']) + }) + + it('should return empty array when no migrations to run up', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js'] + migrationManager.databaseVersion = '1.3.0' + migrationManager.serverVersion = '1.4.0' + const direction = 'up' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal([]) + }) + + it('should return empty array when no migrations to run down', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = [] + migrationManager.databaseVersion = '1.4.0' + migrationManager.serverVersion = '1.3.0' + const direction = 'down' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal([]) + }) + + it('should return down migrations to run when direction is "down" and up migration was not executed', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = [] + migrationManager.databaseVersion = '1.3.0' + migrationManager.serverVersion = '1.0.0' + const direction = 'down' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal(['v1.3.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js']) + }) + + it('should return empty array when direction is "down" and server version is higher than database version', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js'] + migrationManager.databaseVersion = '1.0.0' + migrationManager.serverVersion = '1.3.0' + const direction = 'down' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal([]) + }) + + it('should return empty array when direction is "up" and server version is lower than database version', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js'] + migrationManager.databaseVersion = '1.3.0' + migrationManager.serverVersion = '1.0.0' + const direction = 'up' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal([]) + }) + + it('should return up migrations to run when server version is between migrations', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js'] + migrationManager.databaseVersion = '1.1.0' + migrationManager.serverVersion = '1.2.3' + const direction = 'up' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal(['v1.2.0-migration.js']) + }) + + it('should return down migrations to run when server version is between migrations', () => { + // Arrange + const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }] + const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js'] + migrationManager.databaseVersion = '1.2.0' + migrationManager.serverVersion = '1.1.3' + const direction = 'down' + + // Act + const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction) + + // Assert + expect(result).to.deep.equal(['v1.2.0-migration.js']) + }) + }) +}) diff --git a/test/server/managers/migrations/v1.0.0-migration.js b/test/server/managers/migrations/v1.0.0-migration.js new file mode 100644 index 0000000000..102c8ad550 --- /dev/null +++ b/test/server/managers/migrations/v1.0.0-migration.js @@ -0,0 +1,9 @@ +async function up() { + console.log('v1.0.0 up') +} + +async function down() { + console.log('v1.0.0 down') +} + +module.exports = { up, down } diff --git a/test/server/managers/migrations/v1.1.0-migration.js b/test/server/managers/migrations/v1.1.0-migration.js new file mode 100644 index 0000000000..c4c353b43b --- /dev/null +++ b/test/server/managers/migrations/v1.1.0-migration.js @@ -0,0 +1,9 @@ +async function up() { + console.log('v1.1.0 up') +} + +async function down() { + console.log('v1.1.0 down') +} + +module.exports = { up, down } diff --git a/test/server/managers/migrations/v1.10.0-migration.js b/test/server/managers/migrations/v1.10.0-migration.js new file mode 100644 index 0000000000..8c853738ce --- /dev/null +++ b/test/server/managers/migrations/v1.10.0-migration.js @@ -0,0 +1,9 @@ +async function up() { + console.log('v1.10.0 up') +} + +async function down() { + console.log('v1.10.0 down') +} + +module.exports = { up, down } diff --git a/test/server/managers/migrations/v1.2.0-migration.js b/test/server/managers/migrations/v1.2.0-migration.js new file mode 100644 index 0000000000..d6033d0557 --- /dev/null +++ b/test/server/managers/migrations/v1.2.0-migration.js @@ -0,0 +1,9 @@ +async function up() { + console.log('v1.2.0 up') +} + +async function down() { + console.log('v1.2.0 down') +} + +module.exports = { up, down } diff --git a/test/server/migrations/v0.0.1-migration_example.js b/test/server/migrations/v0.0.1-migration_example.js new file mode 100644 index 0000000000..68ca47a5c3 --- /dev/null +++ b/test/server/migrations/v0.0.1-migration_example.js @@ -0,0 +1,42 @@ +const { DataTypes } = require('sequelize') +const Logger = require('../../../server/Logger') + +/** + * This is an example of an upward migration script. + * + * @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: queryInterface }) { + Logger.info('Running migration_example up...') + Logger.info('Creating example_table...') + await queryInterface.createTable('example_table', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + } + }) + Logger.info('example_table created.') + Logger.info('migration_example up complete.') +} + +/** + * This is an example of a downward migration script. + * + * @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: queryInterface }) { + Logger.info('Running migration_example down...') + Logger.info('Dropping example_table...') + await queryInterface.dropTable('example_table') + Logger.info('example_table dropped.') + Logger.info('migration_example down complete.') +} + +module.exports = { up, down } diff --git a/test/server/migrations/v0.0.1-migration_example.test.js b/test/server/migrations/v0.0.1-migration_example.test.js new file mode 100644 index 0000000000..06ccdc709f --- /dev/null +++ b/test/server/migrations/v0.0.1-migration_example.test.js @@ -0,0 +1,53 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('./v0.0.1-migration_example') +const { Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +describe('migration_example', () => { + let sequelize + let queryInterface + let loggerInfoStub + + beforeEach(() => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + it('should create example_table', async () => { + await up({ context: queryInterface }) + + expect(loggerInfoStub.callCount).to.equal(4) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('Running migration_example up...'))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('Creating example_table...'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('example_table created.'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('migration_example up complete.'))).to.be.true + expect(await queryInterface.showAllTables()).to.include('example_table') + const tableDescription = await queryInterface.describeTable('example_table') + expect(tableDescription).to.deep.equal({ + id: { type: 'INTEGER', allowNull: true, defaultValue: undefined, primaryKey: true, unique: false }, + name: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false } + }) + }) + }) + + describe('down', () => { + it('should drop example_table', async () => { + await up({ context: queryInterface }) + await down({ context: queryInterface }) + + expect(loggerInfoStub.callCount).to.equal(8) + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Running migration_example down...'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Dropping example_table...'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('example_table dropped.'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('migration_example down complete.'))).to.be.true + expect(await queryInterface.showAllTables()).not.to.include('example_table') + }) + }) +}) From b3ce300d32592a59f99deb33bc24867955955c17 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 4 Sep 2024 23:55:16 +0300 Subject: [PATCH 068/539] Fix some packaging and dependency issues --- package-lock.json | 1 + package.json | 7 +++++-- server/managers/MigrationManager.js | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4dd6d347a0..4779817788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.13.3", "license": "GPL-3.0", "dependencies": { + "@rushstack/terminal": "^0.14.0", "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", diff --git a/package.json b/package.json index c7f3b24cc7..745b73830e 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "pkg": { "assets": [ "client/dist/**/*", - "node_modules/sqlite3/lib/binding/**/*.node" + "node_modules/sqlite3/lib/binding/**/*.node", + "node_modules/string-argv/commonjs/package.json" ], "scripts": [ "prod.js", - "server/**/*.js" + "server/**/*.js", + "node_modules/string-argv/commonjs/*.js" ] }, "mocha": { @@ -35,6 +37,7 @@ "author": "advplyr", "license": "GPL-3.0", "dependencies": { + "@rushstack/terminal": "^0.14.0", "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index fa607764eb..2299327f75 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -92,13 +92,13 @@ class MigrationManager { try { await this.copyMigrationsToConfigDir() } catch (error) { - throw new Error('Failed to copy migrations to the config directory.', error) + throw new Error('Failed to copy migrations to the config directory.', { cause: error }) } try { await this.updateMaxVersion(serverVersion) } catch (error) { - throw new Error('Failed to update max version in the database.', error) + throw new Error('Failed to update max version in the database.', { cause: error }) } } @@ -156,6 +156,8 @@ class MigrationManager { await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists + if (!(await fs.pathExists(migrationsSourceDir))) return + const files = await fs.readdir(migrationsSourceDir) await Promise.all( files From 5ec8406653e49b98d3b119c046966922cec77939 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Sep 2024 18:00:59 -0500 Subject: [PATCH 069/539] Cleanup Collection model to remove oldCollection references --- server/models/Collection.js | 205 ++++++++++++++++++------------------ 1 file changed, 103 insertions(+), 102 deletions(-) diff --git a/server/models/Collection.js b/server/models/Collection.js index dcc86e5a56..f2950d4eea 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -115,78 +115,6 @@ class Collection extends Model { .filter((c) => c) } - /** - * Get old collection toJSONExpanded, items filtered for user permissions - * - * @param {import('./User')|null} user - * @param {string[]} [include] - * @returns {Promise} oldCollection.toJSONExpanded - */ - async getOldJsonExpanded(user, include) { - this.books = - (await this.getBooks({ - include: [ - { - model: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - } - ], - order: [Sequelize.literal('`collectionBook.order` ASC')] - })) || [] - - const oldCollection = this.sequelize.models.collection.getOldCollection(this) - - // Filter books using user permissions - // TODO: Handle user permission restrictions on initial query - const books = - this.books?.filter((b) => { - if (user) { - if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { - return false - } - if (b.explicit === true && !user.canAccessExplicitContent) { - return false - } - } - return true - }) || [] - - // Map to library items - const libraryItems = books.map((b) => { - const libraryItem = b.libraryItem - delete b.libraryItem - libraryItem.media = b - return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) - }) - - // Users with restricted permissions will not see this collection - if (!books.length && oldCollection.books.length) { - return null - } - - const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) - - if (include?.includes('rssfeed')) { - const feeds = await this.getFeeds() - if (feeds?.length) { - collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0]) - } - } - - return collectionExpanded - } - /** * Get old collection from Collection * @param {Collection} collectionExpanded @@ -250,36 +178,6 @@ class Collection extends Model { return this.getOldCollection(collection) } - /** - * Get old collection from current - * @returns {Promise} - */ - async getOld() { - this.books = - (await this.getBooks({ - include: [ - { - model: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - } - ], - order: [Sequelize.literal('`collectionBook.order` ASC')] - })) || [] - - return this.sequelize.models.collection.getOldCollection(this) - } - /** * Remove all collections belonging to library * @param {string} libraryId @@ -320,6 +218,109 @@ class Collection extends Model { library.hasMany(Collection) Collection.belongsTo(library) } + + /** + * Get old collection toJSONExpanded, items filtered for user permissions + * + * @param {import('./User')|null} user + * @param {string[]} [include] + * @returns {Promise} oldCollection.toJSONExpanded + */ + async getOldJsonExpanded(user, include) { + this.books = + (await this.getBooks({ + include: [ + { + model: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ], + order: [Sequelize.literal('`collectionBook.order` ASC')] + })) || [] + + // Filter books using user permissions + // TODO: Handle user permission restrictions on initial query + const books = + this.books?.filter((b) => { + if (user) { + if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { + return false + } + if (b.explicit === true && !user.canAccessExplicitContent) { + return false + } + } + return true + }) || [] + + // Map to library items + const libraryItems = books.map((b) => { + const libraryItem = b.libraryItem + delete b.libraryItem + libraryItem.media = b + return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) + }) + + // Users with restricted permissions will not see this collection + if (!books.length && this.books.length) { + return null + } + + const collectionExpanded = this.toOldJSONExpanded(libraryItems) + + if (include?.includes('rssfeed')) { + const feeds = await this.getFeeds() + if (feeds?.length) { + collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0]) + } + } + + return collectionExpanded + } + + /** + * + * @param {string[]} libraryItemIds + * @returns + */ + toOldJSON(libraryItemIds) { + return { + id: this.id, + libraryId: this.libraryId, + name: this.name, + description: this.description, + books: [...libraryItemIds], + lastUpdate: this.updatedAt.valueOf(), + createdAt: this.createdAt.valueOf() + } + } + + /** + * + * @param {import('../objects/LibraryItem')} oldLibraryItems + * @returns + */ + toOldJSONExpanded(oldLibraryItems) { + const json = this.toOldJSON(oldLibraryItems.map((li) => li.id)) + json.books = json.books + .map((libraryItemId) => { + const book = oldLibraryItems.find((li) => li.id === libraryItemId) + return book ? book.toJSONExpanded() : null + }) + .filter((b) => !!b) + return json + } } module.exports = Collection From 84b67abb03086e827b85d035bfe15ba63085b13f Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Sep 2024 17:15:38 -0500 Subject: [PATCH 070/539] Fix:Get all collections API endpoint crashing server #3372 --- server/models/Collection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/Collection.js b/server/models/Collection.js index f2950d4eea..a001dc5b61 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -38,7 +38,7 @@ class Collection extends Model { // Optionally include rssfeed for collection const collectionIncludes = [] - if (include.includes('rssfeed')) { + if (include?.includes('rssfeed')) { collectionIncludes.push({ model: this.sequelize.models.feed }) From a338097514413b5d4c34ebf3c53f52461c5dd8af Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Sep 2024 16:58:40 -0500 Subject: [PATCH 071/539] Update:Cleanup logging on library item update #3362 --- server/models/LibraryItem.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 3f585ee0c7..dd07747a91 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -365,7 +365,23 @@ class LibraryItem extends Model { if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedMedia[key], existingValue, true)) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) + if (key === 'chapters') { + // Handle logging of chapters separately because the object is large + const chaptersRemoved = libraryItemExpanded.media.chapters.filter((ch) => !updatedMedia.chapters.some((uch) => uch.id === ch.id)) + if (chaptersRemoved.length) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters removed: ${chaptersRemoved.map((ch) => ch.title).join(', ')}`) + } + const chaptersAdded = updatedMedia.chapters.filter((uch) => !libraryItemExpanded.media.chapters.some((ch) => ch.id === uch.id)) + if (chaptersAdded.length) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters added: ${chaptersAdded.map((ch) => ch.title).join(', ')}`) + } + if (!chaptersRemoved.length && !chaptersAdded.length) { + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters updated`) + } + } else { + Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from %j to %j`, existingValue, updatedMedia[key])) + } + hasMediaUpdates = true } } From 423a2129d10c6d8aaac9e8c75941fa6283889602 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Sep 2024 17:01:48 -0500 Subject: [PATCH 072/539] Update:Format number for entity total in bookshelf toolbar #3370 --- client/components/app/BookShelfToolbar.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index 00b7ee3439..736e86d39a 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -50,7 +50,7 @@ {{ seriesName }}

- {{ numShowing }} + {{ $formatNumber(numShowing) }}
@@ -63,7 +63,7 @@ From 8a280298092203a7ed83213e6ffacb09f836e14d Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 7 Sep 2024 22:24:19 +0300 Subject: [PATCH 073/539] Make migration management more robust --- server/Database.js | 15 +- server/managers/MigrationManager.js | 177 ++++++++++++------ server/migrations/readme.md | 13 +- test/server/managers/MigrationManager.test.js | 148 +++++++-------- .../migrations/v0.0.1-migration_example.js | 23 +-- .../v0.0.1-migration_example.test.js | 6 +- 6 files changed, 222 insertions(+), 160 deletions(-) diff --git a/server/Database.js b/server/Database.js index 2cf720eb42..289bef0927 100644 --- a/server/Database.js +++ b/server/Database.js @@ -170,14 +170,13 @@ class Database { throw new Error('Database connection failed') } - if (!this.isNew) { - try { - const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath) - await migrationManager.runMigrations(packageJson.version) - } catch (error) { - Logger.error(`[Database] Failed to run migrations`, error) - throw new Error('Database migration failed') - } + try { + const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath) + await migrationManager.init(packageJson.version) + if (!this.isNew) await migrationManager.runMigrations() + } catch (error) { + Logger.error(`[Database] Failed to run migrations`, error) + throw new Error('Database migration failed') } await this.buildModels(force) diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index 2299327f75..b0525ed9b1 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -1,20 +1,21 @@ const { Umzug, SequelizeStorage } = require('umzug') -const { Sequelize } = require('sequelize') +const { Sequelize, DataTypes } = require('sequelize') const semver = require('semver') const path = require('path') +const Module = require('module') const fs = require('../libs/fsExtra') const Logger = require('../Logger') class MigrationManager { + static MIGRATIONS_META_TABLE = 'migrationsMeta' + constructor(sequelize, configPath = global.configPath) { - if (!sequelize || !(sequelize instanceof Sequelize)) { - throw new Error('Sequelize instance is required for MigrationManager.') - } + if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.') this.sequelize = sequelize - if (!configPath) { - throw new Error('Config path is required for MigrationManager.') - } + if (!configPath) throw new Error('Config path is required for MigrationManager.') this.configPath = configPath + this.migrationsSourceDir = path.join(__dirname, '..', 'migrations') + this.initialized = false this.migrationsDir = null this.maxVersion = null this.databaseVersion = null @@ -22,8 +23,36 @@ class MigrationManager { this.umzug = null } - async runMigrations(serverVersion) { - await this.init(serverVersion) + async init(serverVersion) { + if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`) + + this.migrationsDir = path.join(this.configPath, 'migrations') + + this.serverVersion = this.extractVersionFromTag(serverVersion) + if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`) + + await this.fetchVersionsFromDatabase() + if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.') + + if (semver.gt(this.serverVersion, this.maxVersion)) { + try { + await this.copyMigrationsToConfigDir() + } catch (error) { + throw new Error('Failed to copy migrations to the config directory.', { cause: error }) + } + + try { + await this.updateMaxVersion() + } catch (error) { + throw new Error('Failed to update max version in the database.', { cause: error }) + } + } + + this.initialized = true + } + + async runMigrations() { + if (!this.initialized) throw new Error('MigrationManager is not initialized. Call init() first.') const versionCompare = semver.compare(this.serverVersion, this.databaseVersion) if (versionCompare == 0) { @@ -31,6 +60,7 @@ class MigrationManager { return } + this.initUmzug() const migrations = await this.umzug.migrations() const executedMigrations = (await this.umzug.executed()).map((m) => m.name) @@ -51,7 +81,7 @@ class MigrationManager { Logger.info('Created a backup of the original database.') // Run migrations - await this.umzug[migrationDirection]({ migrations: migrationsToRun }) + await this.umzug[migrationDirection]({ migrations: migrationsToRun, rerun: 'ALLOW' }) // Clean up the backup await fs.remove(backupDbPath) @@ -60,7 +90,7 @@ class MigrationManager { } catch (error) { Logger.error('[MigrationManager] Migration failed:', error) - this.sequelize.close() + await this.sequelize.close() // Step 3: If migration fails, save the failed original and restore the backup const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite') @@ -75,45 +105,40 @@ class MigrationManager { } else { Logger.info('[MigrationManager] No migrations to run.') } - } - - async init(serverVersion, umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) { - if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`) - - this.migrationsDir = path.join(this.configPath, 'migrations') - - this.serverVersion = this.extractVersionFromTag(serverVersion) - if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`) - await this.fetchVersionsFromDatabase() - if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.') - - if (semver.gt(this.serverVersion, this.maxVersion)) { - try { - await this.copyMigrationsToConfigDir() - } catch (error) { - throw new Error('Failed to copy migrations to the config directory.', { cause: error }) - } - - try { - await this.updateMaxVersion(serverVersion) - } catch (error) { - throw new Error('Failed to update max version in the database.', { cause: error }) - } - } + await this.updateDatabaseVersion() + } - // Step 4: Initialize the Umzug instance + initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) { if (!this.umzug) { // This check is for dependency injection in tests const cwd = this.migrationsDir const parent = new Umzug({ migrations: { - glob: ['*.js', { cwd }] + glob: ['*.js', { cwd }], + resolve: (params) => { + // make script think it's in migrationsSourceDir + const migrationPath = params.path + const migrationName = params.name + const contents = fs.readFileSync(migrationPath, 'utf8') + const fakePath = path.join(this.migrationsSourceDir, path.basename(migrationPath)) + const module = new Module(fakePath) + module.filename = fakePath + module.paths = Module._nodeModulePaths(this.migrationsSourceDir) + module._compile(contents, fakePath) + const script = module.exports + return { + name: migrationName, + path: migrationPath, + up: script.up, + down: script.down + } + } }, - context: this.sequelize.getQueryInterface(), + context: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger }, storage: umzugStorage, - logger: Logger.info + logger: Logger }) // Sort migrations by version @@ -130,18 +155,38 @@ class MigrationManager { } async fetchVersionsFromDatabase() { - const [result] = await this.sequelize.query("SELECT json_extract(value, '$.version') AS version, json_extract(value, '$.maxVersion') AS maxVersion FROM settings WHERE key = :key", { - replacements: { key: 'server-settings' }, + await this.checkOrCreateMigrationsMetaTable() + + const [{ version }] = await this.sequelize.query("SELECT value as version FROM :migrationsMeta WHERE key = 'version'", { + replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, type: Sequelize.QueryTypes.SELECT }) + this.databaseVersion = version - if (result) { - try { - this.maxVersion = this.extractVersionFromTag(result.maxVersion) || '0.0.0' - this.databaseVersion = this.extractVersionFromTag(result.version) - } catch (error) { - Logger.error('[MigrationManager] Failed to parse server settings from the database.', error) - } + const [{ maxVersion }] = await this.sequelize.query("SELECT value as maxVersion FROM :migrationsMeta WHERE key = 'maxVersion'", { + replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, + type: Sequelize.QueryTypes.SELECT + }) + this.maxVersion = maxVersion + } + + async checkOrCreateMigrationsMetaTable() { + const queryInterface = this.sequelize.getQueryInterface() + if (!(await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE))) { + await queryInterface.createTable(MigrationManager.MIGRATIONS_META_TABLE, { + key: { + type: DataTypes.STRING, + allowNull: false + }, + value: { + type: DataTypes.STRING, + allowNull: false + } + }) + await this.sequelize.query("INSERT INTO :migrationsMeta (key, value) VALUES ('version', :version), ('maxVersion', '0.0.0')", { + replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, + type: Sequelize.QueryTypes.INSERT + }) } } @@ -152,18 +197,16 @@ class MigrationManager { } async copyMigrationsToConfigDir() { - const migrationsSourceDir = path.join(__dirname, '..', 'migrations') - await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists - if (!(await fs.pathExists(migrationsSourceDir))) return + if (!(await fs.pathExists(this.migrationsSourceDir))) return - const files = await fs.readdir(migrationsSourceDir) + const files = await fs.readdir(this.migrationsSourceDir) await Promise.all( files .filter((file) => path.extname(file) === '.js') .map(async (file) => { - const sourceFile = path.join(migrationsSourceDir, file) + const sourceFile = path.join(this.migrationsSourceDir, file) const targetFile = path.join(this.migrationsDir, file) await fs.copy(sourceFile, targetFile) // Asynchronously copy the files }) @@ -189,13 +232,29 @@ class MigrationManager { } } - async updateMaxVersion(serverVersion) { - await this.sequelize.query("UPDATE settings SET value = JSON_SET(value, '$.maxVersion', ?) WHERE key = 'server-settings'", { - replacements: [serverVersion], - type: Sequelize.QueryTypes.UPDATE - }) + async updateMaxVersion() { + try { + await this.sequelize.query("UPDATE :migrationsMeta SET value = :maxVersion WHERE key = 'maxVersion'", { + replacements: { maxVersion: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, + type: Sequelize.QueryTypes.UPDATE + }) + } catch (error) { + throw new Error('Failed to update maxVersion in the migrationsMeta table.', { cause: error }) + } this.maxVersion = this.serverVersion } + + async updateDatabaseVersion() { + try { + await this.sequelize.query("UPDATE :migrationsMeta SET value = :version WHERE key = 'version'", { + replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, + type: Sequelize.QueryTypes.UPDATE + }) + } catch (error) { + throw new Error('Failed to update version in the migrationsMeta table.', { cause: error }) + } + this.databaseVersion = this.serverVersion + } } module.exports = MigrationManager diff --git a/server/migrations/readme.md b/server/migrations/readme.md index c454950e59..5133d7a25c 100644 --- a/server/migrations/readme.md +++ b/server/migrations/readme.md @@ -15,16 +15,18 @@ When writing a migration, keep the following guidelines in mind: - `server_version` should be the version of the server that the migration was created for (this should usually be the next server release). - `migration_name` should be a short description of the changes that the migration makes. -- The script should export two async functions: `up` and `down`. The `up` function should contain the script that applies the changes to the database, and the `down` function should contain the script that undoes the changes. The `up` and `down` functions should accept a single object parameter with a `context` property that contains a reference to a Sequelize [`QueryInterface`](https://sequelize.org/docs/v6/other-topics/query-interface/) object. A typical migration script might look like this: +- The script should export two async functions: `up` and `down`. The `up` function should contain the script that applies the changes to the database, and the `down` function should contain the script that undoes the changes. The `up` and `down` functions should accept a single object parameter with a `context` property that contains a reference to a Sequelize [`QueryInterface`](https://sequelize.org/docs/v6/other-topics/query-interface/) object, and a [Logger](https://github.com/advplyr/audiobookshelf/blob/423a2129d10c6d8aaac9e8c75941fa6283889602/server/Logger.js#L4) object for logging. A typical migration script might look like this: ```javascript - async function up({context: queryInterface}) { + async function up({ context: { queryInterface, logger } }) { // Upwards migration script + logger.info('migrating ...'); ... } - async function down({context: queryInterface}) { + async function down({ context: { queryInterface, logger } }) { // Downward migration script + logger.info('reverting ...'); ... } @@ -33,7 +35,8 @@ When writing a migration, keep the following guidelines in mind: - Always implement both the `up` and `down` functions. - The `up` and `down` functions should be idempotent (i.e., they should be safe to run multiple times). -- It's your responsibility to make sure that the down migration undoes the changes made by the up migration. +- Prefer using only `queryInterface` and `logger` parameters, the `sequelize` module, and node.js built-in modules in your migration scripts. You can require other modules, but be aware that they might not be available or change from they ones you tested with. +- It's your responsibility to make sure that the down migration reverts the changes made by the up migration. - Log detailed information on every step of the migration. Use `Logger.info()` and `Logger.error()`. - Test tour migrations thoroughly before committing them. - write unit tests for your migrations (see `test/server/migrations` for an example) @@ -41,6 +44,6 @@ When writing a migration, keep the following guidelines in mind: ## How migrations are run -Migrations are run automatically when the server starts, when the server detects that the server version has changed. Migrations are always run server version order (from oldest to newest up migrations if the server version increased, and from newest to oldest down migrations if the server version decreased). Only the relevant migrations are run, based on the new and old server versions. +Migrations are run automatically when the server starts, when the server detects that the server version has changed. Migrations are always run in server version order (from oldest to newest up migrations if the server version increased, and from newest to oldest down migrations if the server version decreased). Only the relevant migrations are run, based on the new and old server versions. This means that you can switch between server releases without having to worry about running migrations manually. The server will automatically apply the necessary migrations when it starts. diff --git a/test/server/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js index 49689f6353..8d4f554fe4 100644 --- a/test/server/managers/MigrationManager.test.js +++ b/test/server/managers/MigrationManager.test.js @@ -17,7 +17,6 @@ describe('MigrationManager', () => { let fsMoveStub let fsRemoveStub let fsEnsureDirStub - let fsPathExistsStub let processExitStub let configPath = 'path/to/config' @@ -36,6 +35,7 @@ describe('MigrationManager', () => { migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves() migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves() migrationManager.updateMaxVersion = sinon.stub().resolves() + migrationManager.initUmzug = sinon.stub() migrationManager.umzug = umzugStub loggerInfoStub = sinon.stub(Logger, 'info') loggerErrorStub = sinon.stub(Logger, 'error') @@ -51,24 +51,59 @@ describe('MigrationManager', () => { sinon.restore() }) + describe('init', () => { + it('should initialize the MigrationManager', async () => { + // arrange + migrationManager.databaseVersion = '1.1.0' + migrationManager.maxVersion = '1.1.0' + migrationManager.umzug = null + migrationManager.configPath = __dirname + + // Act + await migrationManager.init(serverVersion) + + // Assert + expect(migrationManager.serverVersion).to.equal(serverVersion) + expect(migrationManager.sequelize).to.equal(sequelizeStub) + expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations')) + expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true + expect(migrationManager.updateMaxVersion.calledOnce).to.be.true + expect(migrationManager.initialized).to.be.true + /* + expect(migrationManager.umzug).to.be.an.instanceOf(Umzug) + expect((await migrationManager.umzug.migrations()).map((m) => m.name)).to.deep.equal(['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js']) + */ + }) + + it('should throw error if serverVersion is not provided', async () => { + // Act + try { + const result = await migrationManager.init() + expect.fail('Expected init to throw an error, but it did not.') + } catch (error) { + expect(error.message).to.equal('Invalid server version: undefined. Expected a version tag like v1.2.3.') + } + }) + }) + describe('runMigrations', () => { it('should run up migrations successfully', async () => { // Arrange migrationManager.databaseVersion = '1.1.0' migrationManager.maxVersion = '1.1.0' + migrationManager.serverVersion = '1.2.0' + migrationManager.initialized = true umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }]) // Act - await migrationManager.runMigrations('1.2.0') + await migrationManager.runMigrations() // Assert - expect(migrationManager.fetchVersionsFromDatabase.calledOnce).to.be.true - expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true - expect(migrationManager.updateMaxVersion.calledOnce).to.be.true + expect(migrationManager.initUmzug.calledOnce).to.be.true expect(umzugStub.up.calledOnce).to.be.true - expect(umzugStub.up.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'] })).to.be.true + expect(umzugStub.up.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'], rerun: 'ALLOW' })).to.be.true expect(fsCopyStub.calledOnce).to.be.true expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true expect(fsRemoveStub.calledOnce).to.be.true @@ -80,19 +115,19 @@ describe('MigrationManager', () => { // Arrange migrationManager.databaseVersion = '1.2.0' migrationManager.maxVersion = '1.2.0' + migrationManager.serverVersion = '1.1.0' + migrationManager.initialized = true umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) // Act - await migrationManager.runMigrations('1.1.0') + await migrationManager.runMigrations() // Assert - expect(migrationManager.fetchVersionsFromDatabase.calledOnce).to.be.true - expect(migrationManager.copyMigrationsToConfigDir.called).to.be.false - expect(migrationManager.updateMaxVersion.called).to.be.false + expect(migrationManager.initUmzug.calledOnce).to.be.true expect(umzugStub.down.calledOnce).to.be.true - expect(umzugStub.down.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'] })).to.be.true + expect(umzugStub.down.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'], rerun: 'ALLOW' })).to.be.true expect(fsCopyStub.calledOnce).to.be.true expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true expect(fsRemoveStub.calledOnce).to.be.true @@ -105,9 +140,10 @@ describe('MigrationManager', () => { migrationManager.serverVersion = '1.2.0' migrationManager.databaseVersion = '1.2.0' migrationManager.maxVersion = '1.2.0' + migrationManager.initialized = true // Act - await migrationManager.runMigrations(serverVersion) + await migrationManager.runMigrations() // Assert expect(umzugStub.up.called).to.be.false @@ -119,6 +155,7 @@ describe('MigrationManager', () => { migrationManager.serverVersion = '1.2.0' migrationManager.databaseVersion = '1.1.0' migrationManager.maxVersion = '1.1.0' + migrationManager.initialized = true umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }]) umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }]) @@ -128,9 +165,10 @@ describe('MigrationManager', () => { const backupDbPath = path.join(configPath, 'absdatabase.backup.sqlite') // Act - await migrationManager.runMigrations(serverVersion) + await migrationManager.runMigrations() // Assert + expect(migrationManager.initUmzug.calledOnce).to.be.true expect(umzugStub.up.calledOnce).to.be.true expect(loggerErrorStub.calledWith(sinon.match('Migration failed'))).to.be.true expect(fsMoveStub.calledWith(originalDbPath, sinon.match('absdatabase.failed.sqlite'), { overwrite: true })).to.be.true @@ -140,44 +178,15 @@ describe('MigrationManager', () => { }) }) - describe('init', () => { - it('should throw error if serverVersion is not provided', async () => { - // Act - try { - const result = await migrationManager.init() - expect.fail('Expected init to throw an error, but it did not.') - } catch (error) { - expect(error.message).to.equal('Invalid server version: undefined. Expected a version tag like v1.2.3.') - } - }) - - it('should initialize the MigrationManager', async () => { - // arrange - migrationManager.databaseVersion = '1.1.0' - migrationManager.maxVersion = '1.1.0' - migrationManager.umzug = null - migrationManager.configPath = __dirname - - // Act - await migrationManager.init(serverVersion, memoryStorage()) - - // Assert - expect(migrationManager.serverVersion).to.equal('1.2.0') - expect(migrationManager.sequelize).to.equal(sequelizeStub) - expect(migrationManager.umzug).to.be.an.instanceOf(Umzug) - expect((await migrationManager.umzug.migrations()).map((m) => m.name)).to.deep.equal(['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js']) - }) - }) - describe('fetchVersionsFromDatabase', () => { - it('should fetch versions from a real database', async () => { + it('should fetch versions from the migrationsMeta table', async () => { // Arrange const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) - const serverSettings = { version: 'v1.1.0', maxVersion: 'v1.1.0' } - // Create a settings table with a single row - await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') - await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } }) + // Create a migrationsMeta table and populate it with version and maxVersion + await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))') + await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')") const migrationManager = new MigrationManager(sequelize, configPath) + migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves() // Act await migrationManager.fetchVersionsFromDatabase() @@ -187,35 +196,23 @@ describe('MigrationManager', () => { expect(migrationManager.databaseVersion).to.equal('1.1.0') }) - it('should set versions to null if no result is returned from the database', async () => { + it('should create the migrationsMeta table if it does not exist and fetch versions from it', async () => { // Arrange const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) - await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') - const migrationManager = new MigrationManager(sequelize, configPath) - - // Act - await migrationManager.fetchVersionsFromDatabase() - - // Assert - expect(migrationManager.maxVersion).to.be.null - expect(migrationManager.databaseVersion).to.be.null - }) - - it('should return a default maxVersion if no maxVersion is set in the database', async () => { - // Arrange - const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) - const serverSettings = { version: 'v1.1.0' } - // Create a settings table with a single row - await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') - await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } }) const migrationManager = new MigrationManager(sequelize, configPath) + migrationManager.serverVersion = serverVersion // Act await migrationManager.fetchVersionsFromDatabase() // Assert + const tableDescription = await sequelize.getQueryInterface().describeTable('migrationsMeta') + expect(tableDescription).to.deep.equal({ + key: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }, + value: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false } + }) expect(migrationManager.maxVersion).to.equal('0.0.0') - expect(migrationManager.databaseVersion).to.equal('1.1.0') + expect(migrationManager.databaseVersion).to.equal(serverVersion) }) it('should throw an error if the database query fails', async () => { @@ -223,6 +220,7 @@ describe('MigrationManager', () => { const sequelizeStub = sinon.createStubInstance(Sequelize) sequelizeStub.query.rejects(new Error('Database query failed')) const migrationManager = new MigrationManager(sequelizeStub, configPath) + migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves() // Act try { @@ -239,18 +237,20 @@ describe('MigrationManager', () => { it('should update the maxVersion in the database', async () => { // Arrange const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) - const serverSettings = { version: 'v1.1.0', maxVersion: 'v1.1.0' } - // Create a settings table with a single row - await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') - await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } }) + // Create a migrationsMeta table and populate it with version and maxVersion + await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))') + await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')") const migrationManager = new MigrationManager(sequelize, configPath) + migrationManager.serverVersion = '1.2.0' // Act - await migrationManager.updateMaxVersion('v1.2.0') + await migrationManager.updateMaxVersion() // Assert - const [result] = await sequelize.query("SELECT json_extract(value, '$.maxVersion') AS maxVersion FROM settings WHERE key = :key", { replacements: { key: 'server-settings' }, type: Sequelize.QueryTypes.SELECT }) - expect(result.maxVersion).to.equal('v1.2.0') + const [{ maxVersion }] = await sequelize.query("SELECT value AS maxVersion FROM migrationsMeta WHERE key = 'maxVersion'", { + type: Sequelize.QueryTypes.SELECT + }) + expect(maxVersion).to.equal('1.2.0') }) }) diff --git a/test/server/migrations/v0.0.1-migration_example.js b/test/server/migrations/v0.0.1-migration_example.js index 68ca47a5c3..a000de07a4 100644 --- a/test/server/migrations/v0.0.1-migration_example.js +++ b/test/server/migrations/v0.0.1-migration_example.js @@ -1,15 +1,15 @@ const { DataTypes } = require('sequelize') -const Logger = require('../../../server/Logger') /** * This is an example of an upward migration script. * * @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object. + * @param {import { Logger } from "../../../server/Logger";} options.context.logger - a Logger object. * @returns {Promise} - A promise that resolves when the migration is complete. */ -async function up({ context: queryInterface }) { - Logger.info('Running migration_example up...') - Logger.info('Creating example_table...') +async function up({ context: { queryInterface, logger } }) { + logger.info('Running migration_example up...') + logger.info('Creating example_table...') await queryInterface.createTable('example_table', { id: { type: DataTypes.INTEGER, @@ -21,22 +21,23 @@ async function up({ context: queryInterface }) { allowNull: false } }) - Logger.info('example_table created.') - Logger.info('migration_example up complete.') + logger.info('example_table created.') + logger.info('migration_example up complete.') } /** * This is an example of a downward migration script. * * @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object. + * @param {import { Logger } from "../../../server/Logger";} options.context.logger - a Logger object. * @returns {Promise} - A promise that resolves when the migration is complete. */ -async function down({ context: queryInterface }) { - Logger.info('Running migration_example down...') - Logger.info('Dropping example_table...') +async function down({ context: { queryInterface, logger } }) { + logger.info('Running migration_example down...') + logger.info('Dropping example_table...') await queryInterface.dropTable('example_table') - Logger.info('example_table dropped.') - Logger.info('migration_example down complete.') + logger.info('example_table dropped.') + logger.info('migration_example down complete.') } module.exports = { up, down } diff --git a/test/server/migrations/v0.0.1-migration_example.test.js b/test/server/migrations/v0.0.1-migration_example.test.js index 06ccdc709f..87300c1127 100644 --- a/test/server/migrations/v0.0.1-migration_example.test.js +++ b/test/server/migrations/v0.0.1-migration_example.test.js @@ -21,7 +21,7 @@ describe('migration_example', () => { describe('up', () => { it('should create example_table', async () => { - await up({ context: queryInterface }) + await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(4) expect(loggerInfoStub.getCall(0).calledWith(sinon.match('Running migration_example up...'))).to.be.true @@ -39,8 +39,8 @@ describe('migration_example', () => { describe('down', () => { it('should drop example_table', async () => { - await up({ context: queryInterface }) - await down({ context: queryInterface }) + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Running migration_example down...'))).to.be.true From 4ddd2788f0bedfa4b820af46dd64e67e11ea68c8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 7 Sep 2024 16:52:42 -0500 Subject: [PATCH 074/539] Fix:Byte conversion to use 1000 instead of 1024 to be accurate with abbrevs #3386 --- client/plugins/utils.js | 2 +- server/utils/fileUtils.js | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/client/plugins/utils.js b/client/plugins/utils.js index ffcd33ad26..160ff9439c 100644 --- a/client/plugins/utils.js +++ b/client/plugins/utils.js @@ -11,7 +11,7 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { if (isNaN(bytes) || bytes == 0) { return '0 Bytes' } - const k = 1024 + const k = 1000 const dm = decimals < 0 ? 0 : decimals const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index e4bb53a009..b0c73d6c6e 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -131,19 +131,6 @@ async function readTextFile(path) { } module.exports.readTextFile = readTextFile -function bytesPretty(bytes, decimals = 0) { - if (bytes === 0) { - return '0 Bytes' - } - const k = 1000 - var dm = decimals < 0 ? 0 : decimals - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - if (i > 2 && dm === 0) dm = 1 - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] -} -module.exports.bytesPretty = bytesPretty - /** * Get array of files inside dir * @param {string} path From 6fb1202c1cacf5a29066463f32a8b9fdee8ab7a9 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 8 Sep 2024 21:33:32 +0300 Subject: [PATCH 075/539] Put umzug in server/libs and remove unneeded dependencies from it --- package-lock.json | 486 +----------------- package.json | 8 +- server/libs/umzug/LICENSE | 21 + server/libs/umzug/index.js | 31 ++ server/libs/umzug/storage/contract.js | 18 + server/libs/umzug/storage/index.js | 24 + server/libs/umzug/storage/json.js | 61 +++ server/libs/umzug/storage/memory.js | 17 + server/libs/umzug/storage/mongodb.js | 31 ++ server/libs/umzug/storage/sequelize.js | 85 +++ server/libs/umzug/templates.js | 32 ++ server/libs/umzug/types.js | 12 + server/libs/umzug/umzug.js | 386 ++++++++++++++ server/managers/MigrationManager.js | 89 ++-- test/server/managers/MigrationManager.test.js | 33 +- 15 files changed, 808 insertions(+), 526 deletions(-) create mode 100644 server/libs/umzug/LICENSE create mode 100644 server/libs/umzug/index.js create mode 100644 server/libs/umzug/storage/contract.js create mode 100644 server/libs/umzug/storage/index.js create mode 100644 server/libs/umzug/storage/json.js create mode 100644 server/libs/umzug/storage/memory.js create mode 100644 server/libs/umzug/storage/mongodb.js create mode 100644 server/libs/umzug/storage/sequelize.js create mode 100644 server/libs/umzug/templates.js create mode 100644 server/libs/umzug/types.js create mode 100644 server/libs/umzug/umzug.js diff --git a/package-lock.json b/package-lock.json index 4779817788..7e0fbf64f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "2.13.3", "license": "GPL-3.0", "dependencies": { - "@rushstack/terminal": "^0.14.0", "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", @@ -27,7 +26,6 @@ "socket.io": "^4.5.4", "sqlite3": "^5.1.6", "ssrf-req-filter": "^1.1.0", - "umzug": "^3.8.1", "xml2js": "^0.5.0" }, "bin": { @@ -621,38 +619,6 @@ "node": ">=6" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -677,104 +643,6 @@ "node": ">=10" } }, - "node_modules/@rushstack/node-core-library": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.7.0.tgz", - "integrity": "sha512-Ff9Cz/YlWu9ce4dmqNBZpA45AEya04XaBFIjV7xTVeEf+y/kTjEasmozqFELXlNG4ROdevss75JrrZ5WgufDkQ==", - "dependencies": { - "ajv": "~8.13.0", - "ajv-draft-04": "~1.0.0", - "ajv-formats": "~3.0.1", - "fs-extra": "~7.0.1", - "import-lazy": "~4.0.0", - "jju": "~1.4.0", - "resolve": "~1.22.1", - "semver": "~7.5.4" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rushstack/node-core-library/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rushstack/terminal": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.14.0.tgz", - "integrity": "sha512-juTKMAMpTIJKudeFkG5slD8Z/LHwNwGZLtU441l/u82XdTBfsP+LbGKJLCNwP5se+DMCT55GB8x9p6+C4UL7jw==", - "dependencies": { - "@rushstack/node-core-library": "5.7.0", - "supports-color": "~8.1.1" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@rushstack/terminal/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@rushstack/terminal/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/@rushstack/ts-command-line": { - "version": "4.22.6", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.22.6.tgz", - "integrity": "sha512-QSRqHT/IfoC5nk9zn6+fgyqOPXHME0BfchII9EUPR19pocsNp/xSbeBCbD3PIR2Lg+Q5qk7OFqk1VhWPMdKHJg==", - "dependencies": { - "@rushstack/terminal": "0.14.0", - "@types/argparse": "1.0.38", - "argparse": "~1.0.9", - "string-argv": "~0.3.1" - } - }, "node_modules/@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -833,11 +701,6 @@ "node": ">= 6" } }, - "node_modules/@types/argparse": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", - "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==" - }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -973,50 +836,6 @@ "node": ">=8" } }, - "node_modules/ajv": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", - "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-draft-04": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", - "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", - "peerDependencies": { - "ajv": "^8.5.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -1101,6 +920,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -1188,11 +1008,12 @@ } }, "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "dependencies": { - "fill-range": "^7.1.1" + "fill-range": "^7.0.1" }, "engines": { "node": ">=8" @@ -1788,17 +1609,6 @@ "integrity": "sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==", "dev": true }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2033,38 +1843,11 @@ "node": ">= 0.6" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2209,19 +1992,6 @@ } ] }, - "node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -2337,6 +2107,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -2410,17 +2181,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -2571,14 +2331,6 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, - "node_modules/import-lazy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", - "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", - "engines": { - "node": ">=8" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2651,24 +2403,11 @@ "node": ">=8" } }, - "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2685,6 +2424,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -2702,6 +2442,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -2940,11 +2681,6 @@ "node": ">=8" } }, - "node_modules/jju": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", - "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==" - }, "node_modules/jose": { "version": "4.15.4", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", @@ -2984,11 +2720,6 @@ "node": ">=4" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3001,14 +2732,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -3231,14 +2954,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "engines": { - "node": ">= 8" - } - }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -3247,18 +2962,6 @@ "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -4263,11 +3966,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -4302,6 +4000,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -4321,14 +4020,6 @@ "node": ">=8" } }, - "node_modules/pony-cause": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", - "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -4378,14 +4069,6 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -4400,25 +4083,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -4504,36 +4168,12 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -4557,15 +4197,6 @@ "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", "integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA==" }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4580,28 +4211,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5079,7 +4688,8 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true }, "node_modules/sqlite3": { "version": "5.1.6", @@ -5147,14 +4757,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5212,17 +4814,6 @@ "node": ">=4" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tar": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", @@ -5274,6 +4865,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -5361,32 +4953,6 @@ "node": ">= 0.8" } }, - "node_modules/umzug": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.8.1.tgz", - "integrity": "sha512-k0HjOc3b/s8vH24BUTvnaFiKhfWI9UQAGpqHDG+3866CGlBTB83Xs5wZ1io1mwYLj/GHvQ34AxKhbpYnWtkRJg==", - "dependencies": { - "@rushstack/ts-command-line": "^4.12.2", - "emittery": "^0.13.0", - "fast-glob": "^3.3.2", - "pony-cause": "^2.1.4", - "type-fest": "^4.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/umzug/node_modules/type-fest": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", - "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -5411,14 +4977,6 @@ "imurmurhash": "^0.1.4" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5457,14 +5015,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 745b73830e..ce2ce580e1 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,11 @@ "pkg": { "assets": [ "client/dist/**/*", - "node_modules/sqlite3/lib/binding/**/*.node", - "node_modules/string-argv/commonjs/package.json" + "node_modules/sqlite3/lib/binding/**/*.node" ], "scripts": [ "prod.js", - "server/**/*.js", - "node_modules/string-argv/commonjs/*.js" + "server/**/*.js" ] }, "mocha": { @@ -37,7 +35,6 @@ "author": "advplyr", "license": "GPL-3.0", "dependencies": { - "@rushstack/terminal": "^0.14.0", "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", @@ -55,7 +52,6 @@ "socket.io": "^4.5.4", "sqlite3": "^5.1.6", "ssrf-req-filter": "^1.1.0", - "umzug": "^3.8.1", "xml2js": "^0.5.0" }, "devDependencies": { diff --git a/server/libs/umzug/LICENSE b/server/libs/umzug/LICENSE new file mode 100644 index 0000000000..653d5f8190 --- /dev/null +++ b/server/libs/umzug/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2017 Sequelize contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/server/libs/umzug/index.js b/server/libs/umzug/index.js new file mode 100644 index 0000000000..d1e2e7c341 --- /dev/null +++ b/server/libs/umzug/index.js @@ -0,0 +1,31 @@ +'use strict' +var __createBinding = + (this && this.__createBinding) || + (Object.create + ? function (o, m, k, k2) { + if (k2 === undefined) k2 = k + var desc = Object.getOwnPropertyDescriptor(m, k) + if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { + enumerable: true, + get: function () { + return m[k] + } + } + } + Object.defineProperty(o, k2, desc) + } + : function (o, m, k, k2) { + if (k2 === undefined) k2 = k + o[k2] = m[k] + }) +var __exportStar = + (this && this.__exportStar) || + function (m, exports) { + for (var p in m) if (p !== 'default' && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p) + } +Object.defineProperty(exports, '__esModule', { value: true }) +__exportStar(require('./umzug'), exports) +__exportStar(require('./storage'), exports) +__exportStar(require('./types'), exports) +//# sourceMappingURL=index.js.map diff --git a/server/libs/umzug/storage/contract.js b/server/libs/umzug/storage/contract.js new file mode 100644 index 0000000000..a572faa32e --- /dev/null +++ b/server/libs/umzug/storage/contract.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.verifyUmzugStorage = exports.isUmzugStorage = void 0; +function isUmzugStorage(arg) { + return (arg && + typeof arg.logMigration === 'function' && + typeof arg.unlogMigration === 'function' && + typeof arg.executed === 'function'); +} +exports.isUmzugStorage = isUmzugStorage; +const verifyUmzugStorage = (arg) => { + if (!isUmzugStorage(arg)) { + throw new Error(`Invalid umzug storage`); + } + return arg; +}; +exports.verifyUmzugStorage = verifyUmzugStorage; +//# sourceMappingURL=contract.js.map \ No newline at end of file diff --git a/server/libs/umzug/storage/index.js b/server/libs/umzug/storage/index.js new file mode 100644 index 0000000000..d99759cc9c --- /dev/null +++ b/server/libs/umzug/storage/index.js @@ -0,0 +1,24 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// codegen:start {preset: barrel} +__exportStar(require("./contract"), exports); +__exportStar(require("./json"), exports); +__exportStar(require("./memory"), exports); +__exportStar(require("./mongodb"), exports); +__exportStar(require("./sequelize"), exports); +// codegen:end +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/server/libs/umzug/storage/json.js b/server/libs/umzug/storage/json.js new file mode 100644 index 0000000000..bd3a2aba7e --- /dev/null +++ b/server/libs/umzug/storage/json.js @@ -0,0 +1,61 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JSONStorage = void 0; +const fs_1 = require("fs"); +const path = __importStar(require("path")); +const filesystem = { + /** reads a file as a string or returns null if file doesn't exist */ + async readAsync(filepath) { + return fs_1.promises.readFile(filepath).then(c => c.toString(), () => null); + }, + /** writes a string as file contents, creating its parent directory if necessary */ + async writeAsync(filepath, content) { + await fs_1.promises.mkdir(path.dirname(filepath), { recursive: true }); + await fs_1.promises.writeFile(filepath, content); + }, +}; +class JSONStorage { + constructor(options) { + var _a; + this.path = (_a = options === null || options === void 0 ? void 0 : options.path) !== null && _a !== void 0 ? _a : path.join(process.cwd(), 'umzug.json'); + } + async logMigration({ name: migrationName }) { + const loggedMigrations = await this.executed(); + loggedMigrations.push(migrationName); + await filesystem.writeAsync(this.path, JSON.stringify(loggedMigrations, null, 2)); + } + async unlogMigration({ name: migrationName }) { + const loggedMigrations = await this.executed(); + const updatedMigrations = loggedMigrations.filter(name => name !== migrationName); + await filesystem.writeAsync(this.path, JSON.stringify(updatedMigrations, null, 2)); + } + async executed() { + const content = await filesystem.readAsync(this.path); + return content ? JSON.parse(content) : []; + } +} +exports.JSONStorage = JSONStorage; +//# sourceMappingURL=json.js.map \ No newline at end of file diff --git a/server/libs/umzug/storage/memory.js b/server/libs/umzug/storage/memory.js new file mode 100644 index 0000000000..fd3ac2ec8a --- /dev/null +++ b/server/libs/umzug/storage/memory.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.memoryStorage = void 0; +const memoryStorage = () => { + let executed = []; + return { + async logMigration({ name }) { + executed.push(name); + }, + async unlogMigration({ name }) { + executed = executed.filter(n => n !== name); + }, + executed: async () => [...executed], + }; +}; +exports.memoryStorage = memoryStorage; +//# sourceMappingURL=memory.js.map \ No newline at end of file diff --git a/server/libs/umzug/storage/mongodb.js b/server/libs/umzug/storage/mongodb.js new file mode 100644 index 0000000000..111713300b --- /dev/null +++ b/server/libs/umzug/storage/mongodb.js @@ -0,0 +1,31 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MongoDBStorage = void 0; +function isMongoDBCollectionOptions(arg) { + return Boolean(arg.collection); +} +class MongoDBStorage { + constructor(options) { + var _a, _b; + if (!options || (!options.collection && !options.connection)) { + throw new Error('MongoDB Connection or Collection required'); + } + this.collection = isMongoDBCollectionOptions(options) + ? options.collection + : options.connection.collection((_a = options.collectionName) !== null && _a !== void 0 ? _a : 'migrations'); + this.connection = options.connection; // TODO remove this + this.collectionName = (_b = options.collectionName) !== null && _b !== void 0 ? _b : 'migrations'; // TODO remove this + } + async logMigration({ name: migrationName }) { + await this.collection.insertOne({ migrationName }); + } + async unlogMigration({ name: migrationName }) { + await this.collection.deleteOne({ migrationName }); + } + async executed() { + const records = await this.collection.find({}).sort({ migrationName: 1 }).toArray(); + return records.map(r => r.migrationName); + } +} +exports.MongoDBStorage = MongoDBStorage; +//# sourceMappingURL=mongodb.js.map \ No newline at end of file diff --git a/server/libs/umzug/storage/sequelize.js b/server/libs/umzug/storage/sequelize.js new file mode 100644 index 0000000000..784ca0bf72 --- /dev/null +++ b/server/libs/umzug/storage/sequelize.js @@ -0,0 +1,85 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SequelizeStorage = void 0; +const DIALECTS_WITH_CHARSET_AND_COLLATE = new Set(['mysql', 'mariadb']); +class SequelizeStorage { + /** + Constructs Sequelize based storage. Migrations will be stored in a SequelizeMeta table using the given instance of Sequelize. + + If a model is given, it will be used directly as the model for the SequelizeMeta table. Otherwise, it will be created automatically according to the given options. + + If the table does not exist it will be created automatically upon the logging of the first migration. + */ + constructor(options) { + var _a, _b, _c, _d, _e, _f; + if (!options || (!options.model && !options.sequelize)) { + throw new Error('One of "sequelize" or "model" storage option is required'); + } + this.sequelize = (_a = options.sequelize) !== null && _a !== void 0 ? _a : options.model.sequelize; + this.columnType = (_b = options.columnType) !== null && _b !== void 0 ? _b : this.sequelize.constructor.DataTypes.STRING; + this.columnName = (_c = options.columnName) !== null && _c !== void 0 ? _c : 'name'; + this.timestamps = (_d = options.timestamps) !== null && _d !== void 0 ? _d : false; + this.modelName = (_e = options.modelName) !== null && _e !== void 0 ? _e : 'SequelizeMeta'; + this.tableName = options.tableName; + this.schema = options.schema; + this.model = (_f = options.model) !== null && _f !== void 0 ? _f : this.getModel(); + } + getModel() { + var _a; + if (this.sequelize.isDefined(this.modelName)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.sequelize.model(this.modelName); + } + const dialectName = (_a = this.sequelize.dialect) === null || _a === void 0 ? void 0 : _a.name; + const hasCharsetAndCollate = dialectName && DIALECTS_WITH_CHARSET_AND_COLLATE.has(dialectName); + return this.sequelize.define(this.modelName, { + [this.columnName]: { + type: this.columnType, + allowNull: false, + unique: true, + primaryKey: true, + autoIncrement: false, + }, + }, { + tableName: this.tableName, + schema: this.schema, + timestamps: this.timestamps, + charset: hasCharsetAndCollate ? 'utf8' : undefined, + collate: hasCharsetAndCollate ? 'utf8_unicode_ci' : undefined, + }); + } + async syncModel() { + await this.model.sync(); + } + async logMigration({ name: migrationName }) { + await this.syncModel(); + await this.model.create({ + [this.columnName]: migrationName, + }); + } + async unlogMigration({ name: migrationName }) { + await this.syncModel(); + await this.model.destroy({ + where: { + [this.columnName]: migrationName, + }, + }); + } + async executed() { + await this.syncModel(); + const migrations = await this.model.findAll({ order: [[this.columnName, 'ASC']] }); + return migrations.map(migration => { + const name = migration[this.columnName]; + if (typeof name !== 'string') { + throw new TypeError(`Unexpected migration name type: expected string, got ${typeof name}`); + } + return name; + }); + } + // TODO remove this + _model() { + return this.model; + } +} +exports.SequelizeStorage = SequelizeStorage; +//# sourceMappingURL=sequelize.js.map \ No newline at end of file diff --git a/server/libs/umzug/templates.js b/server/libs/umzug/templates.js new file mode 100644 index 0000000000..49d3716ce1 --- /dev/null +++ b/server/libs/umzug/templates.js @@ -0,0 +1,32 @@ +'use strict' +/* eslint-disable unicorn/template-indent */ +// templates for migration file creation +Object.defineProperty(exports, '__esModule', { value: true }) +exports.sqlDown = exports.sqlUp = exports.mjs = exports.ts = exports.js = void 0 +exports.js = ` +/** @type {import('umzug').MigrationFn} */ +exports.up = async params => {}; + +/** @type {import('umzug').MigrationFn} */ +exports.down = async params => {}; +`.trimStart() +exports.ts = ` +import type { MigrationFn } from 'umzug'; + +export const up: MigrationFn = async params => {}; +export const down: MigrationFn = async params => {}; +`.trimStart() +exports.mjs = ` +/** @type {import('umzug').MigrationFn} */ +export const up = async params => {}; + +/** @type {import('umzug').MigrationFn} */ +export const down = async params => {}; +`.trimStart() +exports.sqlUp = ` +-- up migration +`.trimStart() +exports.sqlDown = ` +-- down migration +`.trimStart() +//# sourceMappingURL=templates.js.map diff --git a/server/libs/umzug/types.js b/server/libs/umzug/types.js new file mode 100644 index 0000000000..8452b09b40 --- /dev/null +++ b/server/libs/umzug/types.js @@ -0,0 +1,12 @@ +'use strict' +Object.defineProperty(exports, '__esModule', { value: true }) +exports.RerunBehavior = void 0 +exports.RerunBehavior = { + /** Hard error if an up migration that has already been run, or a down migration that hasn't, is encountered */ + THROW: 'THROW', + /** Silently skip up migrations that have already been run, or down migrations that haven't */ + SKIP: 'SKIP', + /** Re-run up migrations that have already been run, or down migrations that haven't */ + ALLOW: 'ALLOW' +} +//# sourceMappingURL=types.js.map diff --git a/server/libs/umzug/umzug.js b/server/libs/umzug/umzug.js new file mode 100644 index 0000000000..916248750c --- /dev/null +++ b/server/libs/umzug/umzug.js @@ -0,0 +1,386 @@ +'use strict' +var __createBinding = + (this && this.__createBinding) || + (Object.create + ? function (o, m, k, k2) { + if (k2 === undefined) k2 = k + var desc = Object.getOwnPropertyDescriptor(m, k) + if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { + enumerable: true, + get: function () { + return m[k] + } + } + } + Object.defineProperty(o, k2, desc) + } + : function (o, m, k, k2) { + if (k2 === undefined) k2 = k + o[k2] = m[k] + }) +var __setModuleDefault = + (this && this.__setModuleDefault) || + (Object.create + ? function (o, v) { + Object.defineProperty(o, 'default', { enumerable: true, value: v }) + } + : function (o, v) { + o['default'] = v + }) +var __importStar = + (this && this.__importStar) || + function (mod) { + if (mod && mod.__esModule) return mod + var result = {} + if (mod != null) for (var k in mod) if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k) + __setModuleDefault(result, mod) + return result + } +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod } + } +var _a +Object.defineProperty(exports, '__esModule', { value: true }) +exports.Umzug = exports.MigrationError = void 0 +const fs = __importStar(require('fs')) +const path = __importStar(require('path')) +const storage_1 = require('./storage') +const templates = __importStar(require('./templates')) +const types_1 = require('./types') +class MigrationError extends Error { + // TODO [>=4.0.0] Take a `{ cause: ... }` options bag like the default `Error`, it looks like this because of verror backwards-compatibility. + constructor(migration, original) { + super(`Migration ${migration.name} (${migration.direction}) failed: ${MigrationError.errorString(original)}`, { + cause: original + }) + this.name = 'MigrationError' + this.migration = migration + } + // TODO [>=4.0.0] Remove this backwards-compatibility alias + get info() { + return this.migration + } + static errorString(cause) { + return cause instanceof Error ? `Original error: ${cause.message}` : `Non-error value thrown. See info for full props: ${cause}` + } +} +exports.MigrationError = MigrationError +class Umzug { + /** creates a new Umzug instance */ + constructor(options) { + var _b + this.options = options + this.storage = (0, storage_1.verifyUmzugStorage)((_b = options.storage) !== null && _b !== void 0 ? _b : new storage_1.JSONStorage()) + this.migrations = this.getMigrationsResolver(this.options.migrations) + } + logging(message) { + var _b + ;(_b = this.options.logger) === null || _b === void 0 ? void 0 : _b.info(message) + } + /** Get the list of migrations which have already been applied */ + async executed() { + return this.runCommand('executed', async ({ context }) => { + const list = await this._executed(context) + // We do the following to not expose the `up` and `down` functions to the user + return list.map((m) => ({ name: m.name, path: m.path })) + }) + } + /** Get the list of migrations which have already been applied */ + async _executed(context) { + const [migrations, executedNames] = await Promise.all([this.migrations(context), this.storage.executed({ context })]) + const executedSet = new Set(executedNames) + return migrations.filter((m) => executedSet.has(m.name)) + } + /** Get the list of migrations which are yet to be applied */ + async pending() { + return this.runCommand('pending', async ({ context }) => { + const list = await this._pending(context) + // We do the following to not expose the `up` and `down` functions to the user + return list.map((m) => ({ name: m.name, path: m.path })) + }) + } + async _pending(context) { + const [migrations, executedNames] = await Promise.all([this.migrations(context), this.storage.executed({ context })]) + const executedSet = new Set(executedNames) + return migrations.filter((m) => !executedSet.has(m.name)) + } + async runCommand(command, cb) { + const context = await this.getContext() + return await cb({ context }) + } + /** + * Apply migrations. By default, runs all pending migrations. + * @see MigrateUpOptions for other use cases using `to`, `migrations` and `rerun`. + */ + async up(options = {}) { + const eligibleMigrations = async (context) => { + var _b + if (options.migrations && options.rerun === types_1.RerunBehavior.ALLOW) { + // Allow rerun means the specified migrations should be run even if they've run before - so get all migrations, not just pending + const list = await this.migrations(context) + return this.findMigrations(list, options.migrations) + } + if (options.migrations && options.rerun === types_1.RerunBehavior.SKIP) { + const executedNames = new Set((await this._executed(context)).map((m) => m.name)) + const filteredMigrations = options.migrations.filter((m) => !executedNames.has(m)) + return this.findMigrations(await this.migrations(context), filteredMigrations) + } + if (options.migrations) { + return this.findMigrations(await this._pending(context), options.migrations) + } + const allPending = await this._pending(context) + let sliceIndex = (_b = options.step) !== null && _b !== void 0 ? _b : allPending.length + if (options.to) { + sliceIndex = this.findNameIndex(allPending, options.to) + 1 + } + return allPending.slice(0, sliceIndex) + } + return this.runCommand('up', async ({ context }) => { + const toBeApplied = await eligibleMigrations(context) + for (const m of toBeApplied) { + const start = Date.now() + const params = { name: m.name, path: m.path, context } + this.logging({ event: 'migrating', name: m.name }) + try { + await m.up(params) + } catch (e) { + throw new MigrationError({ direction: 'up', ...params }, e) + } + await this.storage.logMigration(params) + const duration = (Date.now() - start) / 1000 + this.logging({ event: 'migrated', name: m.name, durationSeconds: duration }) + } + return toBeApplied.map((m) => ({ name: m.name, path: m.path })) + }) + } + /** + * Revert migrations. By default, the last executed migration is reverted. + * @see MigrateDownOptions for other use cases using `to`, `migrations` and `rerun`. + */ + async down(options = {}) { + const eligibleMigrations = async (context) => { + var _b + if (options.migrations && options.rerun === types_1.RerunBehavior.ALLOW) { + const list = await this.migrations(context) + return this.findMigrations(list, options.migrations) + } + if (options.migrations && options.rerun === types_1.RerunBehavior.SKIP) { + const pendingNames = new Set((await this._pending(context)).map((m) => m.name)) + const filteredMigrations = options.migrations.filter((m) => !pendingNames.has(m)) + return this.findMigrations(await this.migrations(context), filteredMigrations) + } + if (options.migrations) { + return this.findMigrations(await this._executed(context), options.migrations) + } + const executedReversed = (await this._executed(context)).slice().reverse() + let sliceIndex = (_b = options.step) !== null && _b !== void 0 ? _b : 1 + if (options.to === 0 || options.migrations) { + sliceIndex = executedReversed.length + } else if (options.to) { + sliceIndex = this.findNameIndex(executedReversed, options.to) + 1 + } + return executedReversed.slice(0, sliceIndex) + } + return this.runCommand('down', async ({ context }) => { + var _b + const toBeReverted = await eligibleMigrations(context) + for (const m of toBeReverted) { + const start = Date.now() + const params = { name: m.name, path: m.path, context } + this.logging({ event: 'reverting', name: m.name }) + try { + await ((_b = m.down) === null || _b === void 0 ? void 0 : _b.call(m, params)) + } catch (e) { + throw new MigrationError({ direction: 'down', ...params }, e) + } + await this.storage.unlogMigration(params) + const duration = Number.parseFloat(((Date.now() - start) / 1000).toFixed(3)) + this.logging({ event: 'reverted', name: m.name, durationSeconds: duration }) + } + return toBeReverted.map((m) => ({ name: m.name, path: m.path })) + }) + } + async create(options) { + await this.runCommand('create', async ({ context }) => { + var _b, _c, _d, _e + const isoDate = new Date().toISOString() + const prefixes = { + TIMESTAMP: isoDate.replace(/\.\d{3}Z$/, '').replace(/\W/g, '.'), + DATE: isoDate.split('T')[0].replace(/\W/g, '.'), + NONE: '' + } + const prefixType = (_b = options.prefix) !== null && _b !== void 0 ? _b : 'TIMESTAMP' + const fileBasename = [prefixes[prefixType], options.name].filter(Boolean).join('.') + const allowedExtensions = options.allowExtension ? [options.allowExtension] : ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.sql'] + const existing = await this.migrations(context) + const last = existing.slice(-1)[0] + const folder = options.folder || ((_c = this.options.create) === null || _c === void 0 ? void 0 : _c.folder) || ((last === null || last === void 0 ? void 0 : last.path) && path.dirname(last.path)) + if (!folder) { + throw new Error(`Couldn't infer a directory to generate migration file in. Pass folder explicitly`) + } + const filepath = path.join(folder, fileBasename) + if (!options.allowConfusingOrdering) { + const confusinglyOrdered = existing.find((e) => e.path && e.path >= filepath) + if (confusinglyOrdered) { + throw new Error(`Can't create ${fileBasename}, since it's unclear if it should run before or after existing migration ${confusinglyOrdered.name}. Use allowConfusingOrdering to bypass this error.`) + } + } + const template = + typeof options.content === 'string' + ? async () => [[filepath, options.content]] + : // eslint-disable-next-line @typescript-eslint/unbound-method + (_e = (_d = this.options.create) === null || _d === void 0 ? void 0 : _d.template) !== null && _e !== void 0 + ? _e + : Umzug.defaultCreationTemplate + const toWrite = await template(filepath) + if (toWrite.length === 0) { + toWrite.push([filepath, '']) + } + toWrite.forEach((pair) => { + if (!Array.isArray(pair) || pair.length !== 2) { + throw new Error(`Expected [filepath, content] pair. Check that the file template function returns an array of pairs.`) + } + const ext = path.extname(pair[0]) + if (!allowedExtensions.includes(ext)) { + const allowStr = allowedExtensions.join(', ') + const message = `Extension ${ext} not allowed. Allowed extensions are ${allowStr}. See help for allowExtension to avoid this error.` + throw new Error(message) + } + fs.mkdirSync(path.dirname(pair[0]), { recursive: true }) + fs.writeFileSync(pair[0], pair[1]) + this.logging({ event: 'created', path: pair[0] }) + }) + if (!options.skipVerify) { + const [firstFilePath] = toWrite[0] + const pending = await this._pending(context) + if (!pending.some((p) => p.path && path.resolve(p.path) === path.resolve(firstFilePath))) { + const paths = pending.map((p) => p.path).join(', ') + throw new Error(`Expected ${firstFilePath} to be a pending migration but it wasn't! Pending migration paths: ${paths}. You should investigate this. Use skipVerify to bypass this error.`) + } + } + }) + } + static defaultCreationTemplate(filepath) { + const ext = path.extname(filepath) + if ((ext === '.js' && typeof require.main === 'object') || ext === '.cjs') { + return [[filepath, templates.js]] + } + if (ext === '.ts' || ext === '.mts' || ext === '.cts') { + return [[filepath, templates.ts]] + } + if ((ext === '.js' && require.main === undefined) || ext === '.mjs') { + return [[filepath, templates.mjs]] + } + if (ext === '.sql') { + const downFilepath = path.join(path.dirname(filepath), 'down', path.basename(filepath)) + return [ + [filepath, templates.sqlUp], + [downFilepath, templates.sqlDown] + ] + } + return [] + } + findNameIndex(migrations, name) { + const index = migrations.findIndex((m) => m.name === name) + if (index === -1) { + throw new Error(`Couldn't find migration to apply with name ${JSON.stringify(name)}`) + } + return index + } + findMigrations(migrations, names) { + const map = new Map(migrations.map((m) => [m.name, m])) + return names.map((name) => { + const migration = map.get(name) + if (!migration) { + throw new Error(`Couldn't find migration to apply with name ${JSON.stringify(name)}`) + } + return migration + }) + } + async getContext() { + const { context = {} } = this.options + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return typeof context === 'function' ? context() : context + } + /** helper for parsing input migrations into a callback returning a list of ready-to-run migrations */ + getMigrationsResolver(inputMigrations) { + var _b + if (Array.isArray(inputMigrations)) { + return async () => inputMigrations + } + if (typeof inputMigrations === 'function') { + // Lazy migrations definition, recurse. + return async (ctx) => { + const resolved = await inputMigrations(ctx) + return this.getMigrationsResolver(resolved)(ctx) + } + } + const paths = inputMigrations.files + const resolver = (_b = inputMigrations.resolve) !== null && _b !== void 0 ? _b : Umzug.defaultResolver + return async (context) => { + paths.sort() + return paths.map((unresolvedPath) => { + const filepath = path.resolve(unresolvedPath) + const name = path.basename(filepath) + return { + path: filepath, + ...resolver({ name, path: filepath, context }) + } + }) + } + } +} +exports.Umzug = Umzug +_a = Umzug +Umzug.defaultResolver = ({ name, path: filepath }) => { + if (!filepath) { + throw new Error(`Can't use default resolver for non-filesystem migrations`) + } + const ext = path.extname(filepath) + const languageSpecificHelp = { + '.ts': "TypeScript files can be required by adding `ts-node` as a dependency and calling `require('ts-node/register')` at the program entrypoint before running migrations.", + '.sql': 'Try writing a resolver which reads file content and executes it as a sql query.' + } + languageSpecificHelp['.cts'] = languageSpecificHelp['.ts'] + languageSpecificHelp['.mts'] = languageSpecificHelp['.ts'] + let loadModule + const jsExt = ext.replace(/\.([cm]?)ts$/, '.$1js') + const getModule = async () => { + try { + return await loadModule() + } catch (e) { + if ((e instanceof SyntaxError || e instanceof MissingResolverError) && ext in languageSpecificHelp) { + e.message += '\n\n' + languageSpecificHelp[ext] + } + throw e + } + } + if ((jsExt === '.js' && typeof require.main === 'object') || jsExt === '.cjs') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + loadModule = async () => require(filepath) + } else if (jsExt === '.js' || jsExt === '.mjs') { + loadModule = async () => import(filepath) + } else { + loadModule = async () => { + throw new MissingResolverError(filepath) + } + } + return { + name, + path: filepath, + up: async ({ context }) => (await getModule()).up({ path: filepath, name, context }), + down: async ({ context }) => { + var _b, _c + return (_c = (_b = await getModule()).down) === null || _c === void 0 ? void 0 : _c.call(_b, { path: filepath, name, context }) + } + } +} +class MissingResolverError extends Error { + constructor(filepath) { + super(`No resolver specified for file ${filepath}. See docs for guidance on how to write a custom resolver.`) + } +} +//# sourceMappingURL=umzug.js.map diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index b0525ed9b1..8f08cf8aae 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -1,4 +1,4 @@ -const { Umzug, SequelizeStorage } = require('umzug') +const { Umzug, SequelizeStorage } = require('../libs/umzug') const { Sequelize, DataTypes } = require('sequelize') const semver = require('semver') const path = require('path') @@ -60,7 +60,7 @@ class MigrationManager { return } - this.initUmzug() + await this.initUmzug() const migrations = await this.umzug.migrations() const executedMigrations = (await this.umzug.executed()).map((m) => m.name) @@ -95,11 +95,12 @@ class MigrationManager { // Step 3: If migration fails, save the failed original and restore the backup const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite') await fs.move(originalDbPath, failedDbPath, { overwrite: true }) - await fs.move(backupDbPath, originalDbPath, { overwrite: true }) + Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.') + await fs.move(backupDbPath, originalDbPath, { overwrite: true }) Logger.info('[MigrationManager] Restored the original database from the backup.') - Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.') + Logger.info('[MigrationManager] Migration failed. Exiting Audiobookshelf with code 1.') process.exit(1) } } else { @@ -109,49 +110,47 @@ class MigrationManager { await this.updateDatabaseVersion() } - initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) { - if (!this.umzug) { - // This check is for dependency injection in tests - const cwd = this.migrationsDir - - const parent = new Umzug({ - migrations: { - glob: ['*.js', { cwd }], - resolve: (params) => { - // make script think it's in migrationsSourceDir - const migrationPath = params.path - const migrationName = params.name - const contents = fs.readFileSync(migrationPath, 'utf8') - const fakePath = path.join(this.migrationsSourceDir, path.basename(migrationPath)) - const module = new Module(fakePath) - module.filename = fakePath - module.paths = Module._nodeModulePaths(this.migrationsSourceDir) - module._compile(contents, fakePath) - const script = module.exports - return { - name: migrationName, - path: migrationPath, - up: script.up, - down: script.down - } + async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) { + // This check is for dependency injection in tests + const files = (await fs.readdir(this.migrationsDir)).map((file) => path.join(this.migrationsDir, file)) + + const parent = new Umzug({ + migrations: { + files, + resolve: (params) => { + // make script think it's in migrationsSourceDir + const migrationPath = params.path + const migrationName = params.name + const contents = fs.readFileSync(migrationPath, 'utf8') + const fakePath = path.join(this.migrationsSourceDir, path.basename(migrationPath)) + const module = new Module(fakePath) + module.filename = fakePath + module.paths = Module._nodeModulePaths(this.migrationsSourceDir) + module._compile(contents, fakePath) + const script = module.exports + return { + name: migrationName, + path: migrationPath, + up: script.up, + down: script.down } - }, - context: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger }, - storage: umzugStorage, - logger: Logger - }) + } + }, + context: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger }, + storage: umzugStorage, + logger: Logger + }) - // Sort migrations by version - this.umzug = new Umzug({ - ...parent.options, - migrations: async () => - (await parent.migrations()).sort((a, b) => { - const versionA = this.extractVersionFromTag(a.name) - const versionB = this.extractVersionFromTag(b.name) - return semver.compare(versionA, versionB) - }) - }) - } + // Sort migrations by version + this.umzug = new Umzug({ + ...parent.options, + migrations: async () => + (await parent.migrations()).sort((a, b) => { + const versionA = this.extractVersionFromTag(a.name) + const versionB = this.extractVersionFromTag(b.name) + return semver.compare(versionA, versionB) + }) + }) } async fetchVersionsFromDatabase() { diff --git a/test/server/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js index 8d4f554fe4..ae28c0d118 100644 --- a/test/server/managers/MigrationManager.test.js +++ b/test/server/managers/MigrationManager.test.js @@ -1,11 +1,11 @@ -const { expect, config } = require('chai') +const { expect } = require('chai') const sinon = require('sinon') const { Sequelize } = require('sequelize') const fs = require('../../../server/libs/fsExtra') const Logger = require('../../../server/Logger') const MigrationManager = require('../../../server/managers/MigrationManager') -const { Umzug, memoryStorage } = require('umzug') const path = require('path') +const { Umzug, memoryStorage } = require('../../../server/libs/umzug') describe('MigrationManager', () => { let sequelizeStub @@ -18,7 +18,7 @@ describe('MigrationManager', () => { let fsRemoveStub let fsEnsureDirStub let processExitStub - let configPath = 'path/to/config' + let configPath = '/path/to/config' const serverVersion = '1.2.0' @@ -69,10 +69,6 @@ describe('MigrationManager', () => { expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true expect(migrationManager.updateMaxVersion.calledOnce).to.be.true expect(migrationManager.initialized).to.be.true - /* - expect(migrationManager.umzug).to.be.an.instanceOf(Umzug) - expect((await migrationManager.umzug.migrations()).map((m) => m.name)).to.deep.equal(['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js']) - */ }) it('should throw error if serverVersion is not provided', async () => { @@ -481,4 +477,27 @@ describe('MigrationManager', () => { expect(result).to.deep.equal(['v1.2.0-migration.js']) }) }) + + describe('initUmzug', () => { + it('should initialize the umzug instance with migrations in the proper order', async () => { + // Arrange + const readdirStub = sinon.stub(fs, 'readdir').resolves(['v1.0.0-migration.js', 'v1.10.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js']) + const readFileSyncStub = sinon.stub(fs, 'readFileSync').returns('module.exports = { up: () => {}, down: () => {} }') + const umzugStorage = memoryStorage() + migrationManager = new MigrationManager(sequelizeStub, configPath) + migrationManager.migrationsDir = path.join(configPath, 'migrations') + const resolvedMigrationNames = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js'] + const resolvedMigrationPaths = resolvedMigrationNames.map((name) => path.resolve(path.join(migrationManager.migrationsDir, name))) + + // Act + await migrationManager.initUmzug(umzugStorage) + + // Assert + expect(readdirStub.calledOnce).to.be.true + expect(migrationManager.umzug).to.be.an.instanceOf(Umzug) + const migrations = await migrationManager.umzug.migrations() + expect(migrations.map((m) => m.name)).to.deep.equal(resolvedMigrationNames) + expect(migrations.map((m) => m.path)).to.deep.equal(resolvedMigrationPaths) + }) + }) }) From 422bb8c31c4031a4b42ef39ecaeab74ece5f87e8 Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 9 Sep 2024 15:28:53 +0300 Subject: [PATCH 076/539] Convert webp images to jpeg during metadata embed --- server/utils/ffmpegHelpers.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index e0f5c7da8f..3fa9f63cd6 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -299,6 +299,12 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF '-metadata:s:v', 'comment=Cover' // add comment metadata to cover image stream ]) + const ext = Path.extname(coverFilePath).toLowerCase() + if (ext === '.webp') { + ffmpeg.outputOptions([ + '-c:v mjpeg' // convert webp images to jpeg + ]) + } } else { ffmpeg.outputOptions([ '-map 0:v?' // retain video stream from input file if exists From f892453892ff1149c03ce81cec9a773aac588d5c Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 9 Sep 2024 18:36:12 +0300 Subject: [PATCH 077/539] Fix crash when quick match adds new series --- server/scanner/Scanner.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 69230dae92..06657de228 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -1,6 +1,7 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const { getTitleIgnorePrefix } = require('../utils/index') // Utils const { findMatchingEpisodesInFeed, getPodcastFeed } = require('../utils/podcastUtils') @@ -230,7 +231,7 @@ class Scanner { seriesItem = await Database.seriesModel.create({ name: seriesMatchItem.series, nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series), - libraryId + libraryId: libraryItem.libraryId }) // Update filter data Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id) From 80685afa7e5665543a0d41d464400bbc51c9aa82 Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 9 Sep 2024 19:23:26 +0300 Subject: [PATCH 078/539] Add a try-catch block around custom provider search --- server/finders/BookFinder.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 8aef4d111d..47d1118c0f 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -202,10 +202,14 @@ class BookFinder { * @returns {Promise} */ async getCustomProviderResults(title, author, isbn, providerSlug) { - const books = await this.customProviderAdapter.search(title, author, isbn, providerSlug, 'book', this.#providerResponseTimeout) - if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`) - - return books + try { + const books = await this.customProviderAdapter.search(title, author, isbn, providerSlug, 'book', this.#providerResponseTimeout) + if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`) + return books + } catch (error) { + Logger.error(`Error searching Custom provider '${providerSlug}':`, error) + return [] + } } static TitleCandidates = class { From 37a09907419a3984f353a836333813b6f6f32eb9 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Tue, 3 Sep 2024 04:19:00 +0000 Subject: [PATCH 079/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index 8c65f16f9a..a3fb848f3b 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -98,6 +98,7 @@ "ButtonStats": "Statistika", "ButtonSubmit": "Posreduj", "ButtonTest": "Test", + "ButtonUnlinkOpenId": "Prekini povezavo OpenID", "ButtonUpload": "Naloži", "ButtonUploadBackup": "Naloži varnostno kopijo", "ButtonUploadCover": "Naloži naslovnico", From 0cfd2ee63be86f361f6dee397b2c5e31d8471542 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Tue, 3 Sep 2024 10:36:16 +0000 Subject: [PATCH 080/539] Translated using Weblate (Spanish) Currently translated at 99.7% (972 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/es.json b/client/strings/es.json index 59ba6c3f69..2f7781db02 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -97,6 +97,7 @@ "ButtonStats": "Estadísticas", "ButtonSubmit": "Enviar", "ButtonTest": "Prueba", + "ButtonUnlinkOpenId": "Desvincular OpenID", "ButtonUpload": "Subir", "ButtonUploadBackup": "Subir Respaldo", "ButtonUploadCover": "Subir Portada", From 54b7ed611706cf4d176f19930137e1e444223271 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Sep 2024 13:38:54 +0000 Subject: [PATCH 081/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 128 +++++++++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 7 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 6007cbcbda..df0d77f7c7 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "选择文件", "ButtonClearFilter": "清除过滤器", "ButtonCloseFeed": "关闭源", + "ButtonCloseSession": "关闭开放会话", "ButtonCollections": "收藏", "ButtonConfigureScanner": "配置扫描", "ButtonCreate": "创建", @@ -28,6 +29,9 @@ "ButtonEdit": "编辑", "ButtonEditChapters": "编辑章节", "ButtonEditPodcast": "编辑播客", + "ButtonEnable": "启用", + "ButtonFireAndFail": "故障和失败", + "ButtonFireOnTest": "测试事件触发", "ButtonForceReScan": "强制重新扫描", "ButtonFullPath": "完整路径", "ButtonHide": "隐藏", @@ -46,6 +50,7 @@ "ButtonNevermind": "没有关系", "ButtonNext": "下一个", "ButtonNextChapter": "下一章节", + "ButtonNextItemInQueue": "队列中的下一个项目", "ButtonOk": "确定", "ButtonOpenFeed": "打开源", "ButtonOpenManager": "打开管理器", @@ -55,6 +60,7 @@ "ButtonPlaylists": "播放列表", "ButtonPrevious": "上一个", "ButtonPreviousChapter": "上一章节", + "ButtonProbeAudioFile": "探测音频文件", "ButtonPurgeAllCache": "清理所有缓存", "ButtonPurgeItemsCache": "清理项目缓存", "ButtonQueueAddItem": "添加到队列", @@ -92,6 +98,7 @@ "ButtonStats": "统计数据", "ButtonSubmit": "提交", "ButtonTest": "测试", + "ButtonUnlinkOpenId": "取消 OpenID 链接", "ButtonUpload": "上传", "ButtonUploadBackup": "上传备份", "ButtonUploadCover": "上传封面", @@ -104,6 +111,7 @@ "ErrorUploadFetchMetadataNoResults": "无法获取元数据 - 尝试更新标题和/或作者", "ErrorUploadLacksTitle": "必须有标题", "HeaderAccount": "帐户", + "HeaderAddCustomMetadataProvider": "添加自定义元数据提供商", "HeaderAdvanced": "高级", "HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAudioTracks": "音轨", @@ -118,7 +126,7 @@ "HeaderCover": "封面", "HeaderCurrentDownloads": "当前下载", "HeaderCustomMessageOnLogin": "登录时的自定义消息", - "HeaderCustomMetadataProviders": "自定义元数据提供者", + "HeaderCustomMetadataProviders": "自定义元数据提供商", "HeaderDetails": "详情", "HeaderDownloadQueue": "下载队列", "HeaderEbookFiles": "电子书文件", @@ -149,6 +157,8 @@ "HeaderMetadataToEmbed": "嵌入元数据", "HeaderNewAccount": "新建帐户", "HeaderNewLibrary": "新建媒体库", + "HeaderNotificationCreate": "创建通知", + "HeaderNotificationUpdate": "更新通知", "HeaderNotifications": "通知", "HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证", "HeaderOpenRSSFeed": "打开 RSS 源", @@ -206,6 +216,7 @@ "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddedAt": "添加于", + "LabelAddedDate": "添加 {0}", "LabelAdminUsersOnly": "仅限管理员用户", "LabelAll": "全部", "LabelAllUsers": "所有用户", @@ -235,6 +246,7 @@ "LabelBitrate": "比特率", "LabelBooks": "图书", "LabelButtonText": "按钮文本", + "LabelByAuthor": "由 {0}", "LabelChangePassword": "修改密码", "LabelChannels": "声道", "LabelChapterTitle": "章节标题", @@ -244,6 +256,7 @@ "LabelClosePlayer": "关闭播放器", "LabelCodec": "编解码", "LabelCollapseSeries": "折叠系列", + "LabelCollapseSubSeries": "折叠子系列", "LabelCollection": "收藏", "LabelCollections": "收藏", "LabelComplete": "已完成", @@ -294,8 +307,10 @@ "LabelEpisode": "剧集", "LabelEpisodeTitle": "剧集标题", "LabelEpisodeType": "剧集类型", + "LabelEpisodes": "剧集", "LabelExample": "示例", "LabelExpandSeries": "展开系列", + "LabelExpandSubSeries": "展开子系列", "LabelExplicit": "信息准确", "LabelExplicitChecked": "明确(已选中)", "LabelExplicitUnchecked": "不明确 (未选中)", @@ -304,7 +319,9 @@ "LabelFetchingMetadata": "正在获取元数据", "LabelFile": "文件", "LabelFileBirthtime": "文件创建时间", + "LabelFileBornDate": "生于 {0}", "LabelFileModified": "文件修改时间", + "LabelFileModifiedDate": "已修改 {0}", "LabelFilename": "文件名", "LabelFilterByUser": "按用户筛选", "LabelFindEpisodes": "查找剧集", @@ -360,6 +377,7 @@ "LabelLess": "较少", "LabelLibrariesAccessibleToUser": "用户可访问的媒体库", "LabelLibrary": "媒体库", + "LabelLibraryFilterSublistEmpty": "没有 {0}", "LabelLibraryItem": "媒体库项目", "LabelLibraryName": "媒体库名称", "LabelLimit": "限制", @@ -371,13 +389,13 @@ "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelLowestPriority": "最低优先级", "LabelMatchExistingUsersBy": "匹配现有用户", - "LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过SSO提供商提供的唯一 id 进行匹配", + "LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过 SSO 提供商提供的唯一 id 进行匹配", "LabelMediaPlayer": "媒体播放器", "LabelMediaType": "媒体类型", "LabelMetaTag": "元数据标签", "LabelMetaTags": "元标签", "LabelMetadataOrderOfPrecedenceDescription": "较高优先级的元数据源将覆盖较低优先级的元数据源", - "LabelMetadataProvider": "元数据提供者", + "LabelMetadataProvider": "元数据提供商", "LabelMinute": "分钟", "LabelMinutes": "分钟", "LabelMissing": "丢失", @@ -396,7 +414,7 @@ "LabelNewestEpisodes": "最新剧集", "LabelNextBackupDate": "下次备份日期", "LabelNextScheduledRun": "下次任务运行", - "LabelNoCustomMetadataProviders": "没有自定义元数据提供程序", + "LabelNoCustomMetadataProviders": "没有自定义元数据提供商", "LabelNoEpisodesSelected": "未选择任何剧集", "LabelNotFinished": "未听完", "LabelNotStarted": "未开始", @@ -412,7 +430,7 @@ "LabelNotificationsMaxQueueSizeHelp": "通知事件被限制为每秒触发 1 个. 如果队列处于最大大小, 则将忽略事件. 这可以防止通知垃圾邮件.", "LabelNumberOfBooks": "图书数量", "LabelNumberOfEpisodes": "# 集", - "LabelOpenIDAdvancedPermsClaimDescription": "OpenID 声明的名称, 该声明包含应用程序内用户操作的高级权限, 该权限将应用于非管理员角色(如果已配置). 如果响应中缺少声明, 获取 ABS 的权限将被拒绝. 如果缺少单个选项, 它将被视为 禁用. 确保身份提供者的声明与预期结构匹配:", + "LabelOpenIDAdvancedPermsClaimDescription": "OpenID 声明的名称, 该声明包含应用程序内用户操作的高级权限, 该权限将应用于非管理员角色(如果已配置). 如果响应中缺少声明, 获取 ABS 的权限将被拒绝. 如果缺少单个选项, 它将被视为 禁用. 确保身份提供商的声明与预期结构匹配:", "LabelOpenIDClaims": "将以下选项留空以禁用高级组和权限分配, 然后自动分配 'User' 组.", "LabelOpenIDGroupClaimDescription": "OpenID 声明的名称, 该声明包含用户组的列表. 通常称为如果已配置, 应用程序将根据用户的组成员身份自动分配角色, 前提是这些组在声明中以不区分大小写的方式命名为 'Admin', 'User' 或 'Guest'. 声明应包含一个列表, 如果用户属于多个组, 则应用程序将分配与最高访问级别相对应的角色. 如果没有组匹配, 访问将被拒绝.", "LabelOpenRSSFeed": "打开 RSS 源", @@ -430,6 +448,7 @@ "LabelPersonalYearReview": "你的年度回顾 ({0})", "LabelPhotoPathURL": "图片路径或 URL", "LabelPlayMethod": "播放方法", + "LabelPlayerChapterNumberMarker": "{0} 于 {1}", "LabelPlaylists": "播放列表", "LabelPodcast": "播客", "LabelPodcastSearchRegion": "播客搜索地区", @@ -440,9 +459,11 @@ "LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引", "LabelPrimaryEbook": "主电子书", "LabelProgress": "进度", - "LabelProvider": "供应商", + "LabelProvider": "提供商", + "LabelProviderAuthorizationValue": "授权标头值", "LabelPubDate": "出版日期", "LabelPublishYear": "发布年份", + "LabelPublishedDate": "已发布 {0}", "LabelPublisher": "出版商", "LabelPublishers": "出版商", "LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件", @@ -526,6 +547,7 @@ "LabelShowSubtitles": "显示标题", "LabelSize": "文件大小", "LabelSleepTimer": "睡眠定时", + "LabelSlug": "Slug", "LabelStart": "开始", "LabelStartTime": "开始时间", "LabelStarted": "开始于", @@ -587,6 +609,7 @@ "LabelUnabridged": "未删节", "LabelUndo": "撤消", "LabelUnknown": "未知", + "LabelUnknownPublishDate": "未知发布日期", "LabelUpdateCover": "更新封面", "LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面", "LabelUpdateDetails": "更新详细信息", @@ -635,16 +658,22 @@ "MessageCheckingCron": "检查计划任务...", "MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", + "MessageConfirmDeleteDevice": "您确定要删除电子阅读器设备 \"{0}\" 吗?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", "MessageConfirmDeleteLibraryItem": "这将从数据库和文件系统中删除库项目. 你确定吗?", "MessageConfirmDeleteLibraryItems": "这将从数据库和文件系统中删除 {0} 个库项目. 你确定吗?", + "MessageConfirmDeleteMetadataProvider": "是否确实要删除自定义元数据提供商 \"{0}\" ?", + "MessageConfirmDeleteNotification": "您确定要删除此通知吗?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?", "MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?", + "MessageConfirmMarkItemFinished": "您确定要将 \"{0}\" 标记为已完成吗?", + "MessageConfirmMarkItemNotFinished": "您确定要将 \"{0}\" 标记为未完成吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", + "MessageConfirmNotificationTestTrigger": "使用测试数据触发此通知吗?", "MessageConfirmPurgeCache": "清除缓存将删除 /metadata/cache 整个目录.

你确定要删除缓存目录吗?", "MessageConfirmPurgeItemsCache": "清除项目缓存将删除 /metadata/cache/items 整个目录.
你确定吗?", "MessageConfirmQuickEmbed": "警告! 快速嵌入不会备份你的音频文件. 确保你有音频文件的备份.

你是否想继续吗?", @@ -663,7 +692,9 @@ "MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?", "MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.", "MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".", + "MessageConfirmResetProgress": "你确定要重置进度吗?", "MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?", + "MessageConfirmUnlinkOpenId": "您确定要取消该用户与 OpenID 的链接吗?", "MessageDownloadingEpisode": "正在下载剧集", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", "MessageEmbedFailed": "嵌入失败!", @@ -698,6 +729,7 @@ "MessageNoCollections": "没有收藏", "MessageNoCoversFound": "没有找到封面", "MessageNoDescription": "没有描述", + "MessageNoDevices": "没有设备", "MessageNoDownloadsInProgress": "当前没有正在进行的下载", "MessageNoDownloadsQueued": "下载队列无任务", "MessageNoEpisodeMatchesFound": "没有找到任何剧集匹配项", @@ -725,6 +757,7 @@ "MessagePauseChapter": "暂停章节播放", "MessagePlayChapter": "开始章节播放", "MessagePlaylistCreateFromCollection": "从收藏中创建播放列表", + "MessagePleaseWait": "请稍等...", "MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url", "MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.", "MessageRemoveChapter": "移除章节", @@ -785,18 +818,28 @@ "StatsYearInReview": "年度回顾", "ToastAccountUpdateFailed": "账户更新失败", "ToastAccountUpdateSuccess": "帐户已更新", + "ToastAppriseUrlRequired": "必须输入 Apprise URL", "ToastAuthorImageRemoveSuccess": "作者图像已删除", + "ToastAuthorNotFound": "未找到作者 \"{0}\"", + "ToastAuthorRemoveSuccess": "作者已删除", + "ToastAuthorSearchNotFound": "未找到作者", "ToastAuthorUpdateFailed": "作者更新失败", "ToastAuthorUpdateMerged": "作者已合并", "ToastAuthorUpdateSuccess": "作者已更新", "ToastAuthorUpdateSuccessNoImageFound": "作者已更新 (未找到图像)", + "ToastBackupAppliedSuccess": "已应用备份", "ToastBackupCreateFailed": "备份创建失败", "ToastBackupCreateSuccess": "备份已创建", "ToastBackupDeleteFailed": "备份删除失败", "ToastBackupDeleteSuccess": "备份已删除", + "ToastBackupInvalidMaxKeep": "要保留的备份数无效", + "ToastBackupInvalidMaxSize": "最大备份大小无效", + "ToastBackupPathUpdateFailed": "无法更新备份路径", "ToastBackupRestoreFailed": "备份还原失败", "ToastBackupUploadFailed": "上传备份失败", "ToastBackupUploadSuccess": "备份已上传", + "ToastBatchDeleteFailed": "批量删除失败", + "ToastBatchDeleteSuccess": "批量删除成功", "ToastBatchUpdateFailed": "批量更新失败", "ToastBatchUpdateSuccess": "批量更新成功", "ToastBookmarkCreateFailed": "创建书签失败", @@ -808,22 +851,46 @@ "ToastCachePurgeSuccess": "缓存清除成功", "ToastChaptersHaveErrors": "章节有错误", "ToastChaptersMustHaveTitles": "章节必须有标题", + "ToastChaptersRemoved": "已删除章节", + "ToastCollectionItemsAddFailed": "项目添加到收藏夹失败", + "ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功", "ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除", "ToastCollectionRemoveSuccess": "收藏夹已删除", "ToastCollectionUpdateFailed": "更新收藏夹失败", "ToastCollectionUpdateSuccess": "收藏夹已更新", + "ToastCoverUpdateFailed": "封面更新失败", "ToastDeleteFileFailed": "删除文件失败", "ToastDeleteFileSuccess": "文件已删除", + "ToastDeviceAddFailed": "添加设备失败", + "ToastDeviceNameAlreadyExists": "同名的电子阅读器设备已存在", + "ToastDeviceTestEmailFailed": "无法发送测试电子邮件", + "ToastDeviceTestEmailSuccess": "测试邮件已发送", + "ToastDeviceUpdateFailed": "无法更新设备", + "ToastEmailSettingsUpdateFailed": "无法更新电子邮件设置", + "ToastEmailSettingsUpdateSuccess": "电子邮件设置已更新", + "ToastEncodeCancelFailed": "取消编码失败", + "ToastEncodeCancelSucces": "编码已取消", + "ToastEpisodeDownloadQueueClearFailed": "无法清除队列", + "ToastEpisodeDownloadQueueClearSuccess": "剧集下载队列已清空", "ToastErrorCannotShare": "无法在此设备上本地共享", "ToastFailedToLoadData": "加载数据失败", + "ToastFailedToShare": "分享失败", + "ToastFailedToUpdateAccount": "无法更新账户", + "ToastFailedToUpdateUser": "无法更新用户", + "ToastInvalidImageUrl": "图片网址无效", + "ToastInvalidUrl": "网址无效", "ToastItemCoverUpdateFailed": "更新项目封面失败", "ToastItemCoverUpdateSuccess": "项目封面已更新", + "ToastItemDeletedFailed": "删除项目失败", + "ToastItemDeletedSuccess": "已删除项目", "ToastItemDetailsUpdateFailed": "更新项目详细信息失败", "ToastItemDetailsUpdateSuccess": "项目详细信息已更新", "ToastItemMarkedAsFinishedFailed": "无法标记为已听完", "ToastItemMarkedAsFinishedSuccess": "标记为已听完的项目", "ToastItemMarkedAsNotFinishedFailed": "无法标记为未听完", "ToastItemMarkedAsNotFinishedSuccess": "标记为未听完的项目", + "ToastItemUpdateFailed": "更新项目失败", + "ToastItemUpdateSuccess": "项目已更新", "ToastLibraryCreateFailed": "创建媒体库失败", "ToastLibraryCreateSuccess": "媒体库 \"{0}\" 创建成功", "ToastLibraryDeleteFailed": "删除媒体库失败", @@ -832,6 +899,25 @@ "ToastLibraryScanStarted": "媒体库扫描已启动", "ToastLibraryUpdateFailed": "更新图书库失败", "ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新", + "ToastNameEmailRequired": "姓名和电子邮件为必填项", + "ToastNameRequired": "姓名为必填项", + "ToastNewUserCreatedFailed": "无法创建帐户: \"{0}\"", + "ToastNewUserCreatedSuccess": "已创建新帐户", + "ToastNewUserLibraryError": "必须至少选择一个图书馆", + "ToastNewUserPasswordError": "必须有密码, 只有root用户可以有空密码", + "ToastNewUserTagError": "必须至少选择一个标签", + "ToastNewUserUsernameError": "输入用户名", + "ToastNoUpdatesNecessary": "无需更新", + "ToastNotificationCreateFailed": "无法创建通知", + "ToastNotificationDeleteFailed": "删除通知失败", + "ToastNotificationFailedMaximum": "最大失败尝试次数必须 >= 0", + "ToastNotificationQueueMaximum": "最大通知队列必须 >= 0", + "ToastNotificationSettingsUpdateFailed": "无法更新通知设置", + "ToastNotificationSettingsUpdateSuccess": "通知设置已更新", + "ToastNotificationTestTriggerFailed": "无法触发测试通知", + "ToastNotificationTestTriggerSuccess": "触发测试通知", + "ToastNotificationUpdateFailed": "更新通知失败", + "ToastNotificationUpdateSuccess": "通知已更新", "ToastPlaylistCreateFailed": "创建播放列表失败", "ToastPlaylistCreateSuccess": "已成功创建播放列表", "ToastPlaylistRemoveSuccess": "播放列表已删除", @@ -839,24 +925,52 @@ "ToastPlaylistUpdateSuccess": "播放列表已更新", "ToastPodcastCreateFailed": "创建播客失败", "ToastPodcastCreateSuccess": "已成功创建播客", + "ToastPodcastGetFeedFailed": "无法获取播客信息", + "ToastPodcastNoEpisodesInFeed": "RSS 订阅中未找到任何剧集", + "ToastPodcastNoRssFeed": "播客没有 RSS 源", + "ToastProviderCreatedFailed": "无法添加提供商", + "ToastProviderCreatedSuccess": "已添加新提供商", + "ToastProviderNameAndUrlRequired": "名称和网址必需填写", + "ToastProviderRemoveSuccess": "提供商已移除", "ToastRSSFeedCloseFailed": "关闭 RSS 源失败", "ToastRSSFeedCloseSuccess": "RSS 源已关闭", + "ToastRemoveFailed": "删除失败", "ToastRemoveItemFromCollectionFailed": "从收藏中删除项目失败", "ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除", + "ToastRemoveItemsWithIssuesFailed": "无法删除有问题的库项目", + "ToastRemoveItemsWithIssuesSuccess": "已删除有问题的库项目", + "ToastRenameFailed": "重命名失败", + "ToastRescanFailed": "{0} 重新扫描失败", + "ToastRescanRemoved": "重新扫描完成项目已删除", + "ToastRescanUpToDate": "重新扫描完成项目已更新", + "ToastRescanUpdated": "重新扫描完成项目已更新", + "ToastScanFailed": "扫描库项目失败", + "ToastSelectAtLeastOneUser": "至少选择一位用户", "ToastSendEbookToDeviceFailed": "发送电子书到设备失败", "ToastSendEbookToDeviceSuccess": "电子书已经发送到设备 \"{0}\"", "ToastSeriesUpdateFailed": "更新系列失败", "ToastSeriesUpdateSuccess": "系列已更新", "ToastServerSettingsUpdateFailed": "无法更新服务器设置", "ToastServerSettingsUpdateSuccess": "服务器设置已更新", + "ToastSessionCloseFailed": "关闭会话失败", "ToastSessionDeleteFailed": "删除会话失败", "ToastSessionDeleteSuccess": "会话已删除", + "ToastSlugMustChange": "Slug 包含无效字符", + "ToastSlugRequired": "Slug 是必填项", "ToastSocketConnected": "网络已连接", "ToastSocketDisconnected": "网络已断开", "ToastSocketFailedToConnect": "网络连接失败", "ToastSortingPrefixesEmptyError": "必须至少有 1 个排序前缀", "ToastSortingPrefixesUpdateFailed": "无法更新排序前缀", "ToastSortingPrefixesUpdateSuccess": "排序前缀已更新 ({0} 项)", + "ToastTitleRequired": "标题为必填项", + "ToastUnknownError": "未知错误", + "ToastUnlinkOpenIdFailed": "无法取消用户与 OpenID 的关联", + "ToastUnlinkOpenIdSuccess": "用户已取消与 OpenID 的关联", "ToastUserDeleteFailed": "删除用户失败", - "ToastUserDeleteSuccess": "用户已删除" + "ToastUserDeleteSuccess": "用户已删除", + "ToastUserPasswordChangeSuccess": "密码修改成功", + "ToastUserPasswordMismatch": "密码不匹配", + "ToastUserPasswordMustChange": "新密码不能与旧密码相同", + "ToastUserRootRequireName": "必须输入 root 用户名" } From 28358debbc05f739e87e9eee3e165452e1ef41d3 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Thu, 5 Sep 2024 04:56:26 +0000 Subject: [PATCH 082/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index a3fb848f3b..b6ae7f5219 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -290,8 +290,8 @@ "LabelDurationComparisonLonger": "({0} dlje)", "LabelDurationComparisonShorter": "({0} krajše)", "LabelDurationFound": "Najdeno trajanje:", - "LabelEbook": "Eknjiga", - "LabelEbooks": "Eknjige", + "LabelEbook": "E-knjiga", + "LabelEbooks": "E-knjige", "LabelEdit": "Uredi", "LabelEmail": "E-pošta", "LabelEmailSettingsFromAddress": "Iz naslova", @@ -338,8 +338,8 @@ "LabelGenre": "Žanr", "LabelGenres": "Žanri", "LabelHardDeleteFile": "Trdo brisanje datoteke", - "LabelHasEbook": "Ima eknjigo", - "LabelHasSupplementaryEbook": "Ima dodatno eknjigo", + "LabelHasEbook": "Ima e-knjigo", + "LabelHasSupplementaryEbook": "Ima dodatno e-knjigo", "LabelHideSubtitles": "Skrij podnapise", "LabelHighestPriority": "Najvišja prioriteta", "LabelHost": "Gostitelj", @@ -457,7 +457,7 @@ "LabelPort": "Vrata", "LabelPrefixesToIgnore": "Predpone, ki jih je treba prezreti (neobčutljivo na velike in male črke)", "LabelPreventIndexing": "Preprečite, da bi vaš vir indeksirali imeniki podcastov iTunes in Google", - "LabelPrimaryEbook": "Primarna eknjiga", + "LabelPrimaryEbook": "Primarna e-knjiga", "LabelProgress": "Napredek", "LabelProvider": "Ponudnik", "LabelProviderAuthorizationValue": "Vrednost glave avtorizacije", From 48330f6432574c4971a933942a7536c5b26f6e76 Mon Sep 17 00:00:00 2001 From: Soaibuzzaman Date: Fri, 6 Sep 2024 09:17:42 +0000 Subject: [PATCH 083/539] Translated using Weblate (Bengali) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/ --- client/strings/bn.json | 187 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 9 deletions(-) diff --git a/client/strings/bn.json b/client/strings/bn.json index 9334213583..d5856c1148 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -98,6 +98,7 @@ "ButtonStats": "পরিসংখ্যান", "ButtonSubmit": "জমা দিন", "ButtonTest": "পরীক্ষা", + "ButtonUnlinkOpenId": "ওপেন আইডি লিঙ্কমুক্ত করুন", "ButtonUpload": "আপলোড", "ButtonUploadBackup": "আপলোড ব্যাকআপ", "ButtonUploadCover": "কভার আপলোড করুন", @@ -238,7 +239,7 @@ "LabelBackupLocation": "ব্যাকআপ অবস্থান", "LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন", "LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত", - "LabelBackupsMaxBackupSize": "সর্বোচ্চ ব্যাকআপ আকার (GB-তে)", + "LabelBackupsMaxBackupSize": "সর্বোচ্চ ব্যাকআপ আকার (GB-তে) (অসীমের জন্য 0)", "LabelBackupsMaxBackupSizeHelp": "ভুল কনফিগারেশনের বিরুদ্ধে সুরক্ষা হিসেবে ব্যাকআপগুলি ব্যর্থ হবে যদি তারা কনফিগার করা আকার অতিক্রম করে।", "LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন", "LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।", @@ -295,7 +296,7 @@ "LabelEmail": "ইমেইল", "LabelEmailSettingsFromAddress": "ঠিকানা থেকে", "LabelEmailSettingsRejectUnauthorized": "অননুমোদিত সার্টিফিকেট প্রত্যাখ্যান করুন", - "LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to।", + "LabelEmailSettingsRejectUnauthorizedHelp": "SSL প্রমাণপত্রের বৈধতা নিষ্ক্রিয় করা আপনার সংযোগকে নিরাপত্তা ঝুঁকিতে ফেলতে পারে, যেমন ম্যান-ইন-দ্য-মিডল আক্রমণ। শুধুমাত্র এই বিকল্পটি নিষ্ক্রিয় করুন যদি আপনি এর প্রভাবগুলি বুঝতে পারেন এবং আপনি যে মেইল সার্ভারের সাথে সংযোগ করছেন তাকে বিশ্বাস করেন।", "LabelEmailSettingsSecure": "নিরাপদ", "LabelEmailSettingsSecureHelp": "যদি সত্য হয় সার্ভারের সাথে সংযোগ করার সময় সংযোগটি TLS ব্যবহার করবে। মিথ্যা হলে TLS ব্যবহার করা হবে যদি সার্ভার STARTTLS এক্সটেনশন সমর্থন করে। বেশিরভাগ ক্ষেত্রে এই মানটিকে সত্য হিসাবে সেট করুন যদি আপনি পোর্ট 465-এর সাথে সংযোগ করছেন। পোর্ট 587 বা পোর্টের জন্য 25 এটি মিথ্যা রাখুন। (nodemailer.com/smtp/#authentication থেকে)", "LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা", @@ -309,12 +310,18 @@ "LabelEpisodes": "পর্বগুলো", "LabelExample": "উদাহরণ", "LabelExpandSeries": "সিরিজ প্রসারিত করুন", + "LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন", "LabelExplicit": "বিশদ", + "LabelExplicitChecked": "সুস্পষ্ট (পরীক্ষিত)", + "LabelExplicitUnchecked": "অস্পষ্ট (অপরিক্ষীত)", + "LabelExportOPML": "OPML এক্সপোর্ট করুন", "LabelFeedURL": "ফিড ইউআরএল", "LabelFetchingMetadata": "মেটাডেটা আনা হচ্ছে", "LabelFile": "ফাইল", "LabelFileBirthtime": "ফাইল জন্মের সময়", + "LabelFileBornDate": "জন্ম {0}", "LabelFileModified": "ফাইল পরিবর্তিত", + "LabelFileModifiedDate": "পরিবর্তিত {0}", "LabelFilename": "ফাইলের নাম", "LabelFilterByUser": "ব্যবহারকারী দ্বারা ফিল্টারকৃত", "LabelFindEpisodes": "পর্বগুলো খুঁজুন", @@ -322,7 +329,8 @@ "LabelFolder": "ফোল্ডার", "LabelFolders": "ফোল্ডারগুলো", "LabelFontBold": "বোল্ড", - "LabelFontFamily": "ফন্ট পরিবার", + "LabelFontBoldness": "হরফ বোল্ডনেস", + "LabelFontFamily": "হরফ পরিবার", "LabelFontItalic": "ইটালিক", "LabelFontScale": "ফন্ট স্কেল", "LabelFontStrikethrough": "অবচ্ছেদন রেখা", @@ -332,9 +340,11 @@ "LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন", "LabelHasEbook": "ই-বই আছে", "LabelHasSupplementaryEbook": "পরিপূরক ই-বই আছে", + "LabelHideSubtitles": "সাবটাইটেল লুকান", "LabelHighestPriority": "সর্বোচ্চ অগ্রাধিকার", "LabelHost": "নিমন্ত্রণকর্তা", "LabelHour": "ঘন্টা", + "LabelHours": "ঘন্টা", "LabelIcon": "আইকন", "LabelImageURLFromTheWeb": "ওয়েব থেকে ছবির ইউআরএল", "LabelInProgress": "প্রগতিতে আছে", @@ -351,8 +361,11 @@ "LabelIntervalEveryHour": "প্রতি ঘন্টা", "LabelInvert": "উল্টানো", "LabelItem": "আইটেম", + "LabelJumpBackwardAmount": "পিছন দিকে ঝাঁপের পরিমাণ", + "LabelJumpForwardAmount": "সামনের দিকে ঝাঁপের পরিমাণ", "LabelLanguage": "ভাষা", "LabelLanguageDefaultServer": "সার্ভারের ডিফল্ট ভাষা", + "LabelLanguages": "ভাষাসমূহ", "LabelLastBookAdded": "শেষ বই যোগ করা হয়েছে", "LabelLastBookUpdated": "শেষ বই আপডেট করা হয়েছে", "LabelLastSeen": "শেষ দেখা", @@ -364,6 +377,7 @@ "LabelLess": "কম", "LabelLibrariesAccessibleToUser": "ব্যবহারকারীর কাছে অ্যাক্সেসযোগ্য লাইব্রেরি", "LabelLibrary": "লাইব্রেরি", + "LabelLibraryFilterSublistEmpty": "না {0}", "LabelLibraryItem": "লাইব্রেরি আইটেম", "LabelLibraryName": "লাইব্রেরির নাম", "LabelLimit": "সীমা", @@ -383,6 +397,7 @@ "LabelMetadataOrderOfPrecedenceDescription": "উচ্চ অগ্রাধিকারের মেটাডেটার উৎসগুলো নিম্ন অগ্রাধিকারের মেটাডেটা উৎসগুলোকে ওভাররাইড করবে", "LabelMetadataProvider": "মেটাডেটা প্রদানকারী", "LabelMinute": "মিনিট", + "LabelMinutes": "মিনিটস", "LabelMissing": "নিখোঁজ", "LabelMissingEbook": "কোনও ই-বই নেই", "LabelMissingSupplementaryEbook": "কোনও সম্পূরক ই-বই নেই", @@ -399,6 +414,7 @@ "LabelNewestEpisodes": "নতুনতম পর্ব", "LabelNextBackupDate": "পরবর্তী ব্যাকআপ তারিখ", "LabelNextScheduledRun": "পরবর্তী নির্ধারিত দৌড়", + "LabelNoCustomMetadataProviders": "কোনো কাস্টম মেটাডেটা প্রদানকারী নেই", "LabelNoEpisodesSelected": "কোন পর্ব নির্বাচন করা হয়নি", "LabelNotFinished": "সমাপ্ত হয়নি", "LabelNotStarted": "শুরু হয়নি", @@ -421,6 +437,7 @@ "LabelOverwrite": "পুনঃলিখিত", "LabelPassword": "পাসওয়ার্ড", "LabelPath": "পথ", + "LabelPermanent": "স্থায়ী", "LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে", @@ -431,6 +448,7 @@ "LabelPersonalYearReview": "আপনার বছরের পর্যালোচনা ({0})", "LabelPhotoPathURL": "ছবি পথ/ইউআরএল", "LabelPlayMethod": "প্লে পদ্ধতি", + "LabelPlayerChapterNumberMarker": "{1} এর মধ্যে {0}", "LabelPlaylists": "প্লেলিস্ট", "LabelPodcast": "পডকাস্ট", "LabelPodcastSearchRegion": "পডকাস্ট অনুসন্ধান অঞ্চল", @@ -442,15 +460,20 @@ "LabelPrimaryEbook": "প্রাথমিক ই-বই", "LabelProgress": "প্রগতি", "LabelProvider": "প্রদানকারী", + "LabelProviderAuthorizationValue": "অনুমোদন শিরোনামের মান", "LabelPubDate": "প্রকাশের তারিখ", "LabelPublishYear": "প্রকাশের বছর", + "LabelPublishedDate": "প্রকাশিত {0}", "LabelPublisher": "প্রকাশক", + "LabelPublishers": "প্রকাশকরা", "LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল", "LabelRSSFeedCustomOwnerName": "কাস্টম মালিকের নাম", "LabelRSSFeedOpen": "আরএসএস ফিড খুলুন", "LabelRSSFeedPreventIndexing": "সূচীকরণ প্রতিরোধ করুন", "LabelRSSFeedSlug": "আরএসএস ফিড স্লাগ", "LabelRSSFeedURL": "আরএসএস ফিড ইউআরএল", + "LabelRandomly": "এলোমেলোভাবে", + "LabelReAddSeriesToContinueListening": "শোনা চালিয়ে যেতে সিরিজ পুনরায় যোগ করুন", "LabelRead": "পড়ুন", "LabelReadAgain": "আবার পড়ুন", "LabelReadEbookWithoutProgress": "প্রগতি না রেখে ই-বই পড়ুন", @@ -466,6 +489,7 @@ "LabelSearchTitle": "অনুসন্ধান শিরোনাম", "LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN", "LabelSeason": "সেশন", + "LabelSelectAll": "সব নির্বাচন করুন", "LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন", "LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন", "LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন", @@ -488,7 +512,8 @@ "LabelSettingsEnableWatcher": "প্রহরী সক্ষম করুন", "LabelSettingsEnableWatcherForLibrary": "লাইব্রেরির জন্য ফোল্ডার প্রহরী সক্ষম করুন", "LabelSettingsEnableWatcherHelp": "ফাইলের পরিবর্তন শনাক্ত হলে আইটেমগুলির স্বয়ংক্রিয় যোগ/আপডেট সক্ষম করবে। *সার্ভার পুনরায় চালু করতে হবে", - "LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files।", + "LabelSettingsEpubsAllowScriptedContent": "ইপাবে স্ক্রিপ্ট করা বিষয়বস্তুর অনুমতি দিন", + "LabelSettingsEpubsAllowScriptedContentHelp": "ইপাব ফাইলগুলিকে স্ক্রিপ্ট চালানোর অনুমতি দিন। আপনি ইপাব ফাইলগুলির উৎসকে বিশ্বাস না করলে এই সেটিংটি নিষ্ক্রিয় রাখার সুপারিশ করা হলো।", "LabelSettingsExperimentalFeatures": "পরীক্ষামূলক বৈশিষ্ট্য", "LabelSettingsExperimentalFeaturesHelp": "ফিচারের বৈশিষ্ট্য যা আপনার প্রতিক্রিয়া ব্যবহার করতে পারে এবং পরীক্ষায় সহায়তা করতে পারে। গিটহাব আলোচনা খুলতে ক্লিক করুন।", "LabelSettingsFindCovers": "কভার খুঁজুন", @@ -498,7 +523,7 @@ "LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান", - "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের হোম পেজ শেল্ফ প্রথম বইটি দেখায় যেটি সিরিজে শুরু হয়নি যেটিতে অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করা হলে তা শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চালিয়ে যাবে।", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।", "LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন", "LabelSettingsParseSubtitlesHelp": "অডিওবুক ফোল্ডারের নাম থেকে সাবটাইটেল বের করুন৷
সাবটাইটেল অবশ্যই \" - \"
অর্থাৎ \"বুকের শিরোনাম - এখানে একটি সাবটাইটেল\" এর সাবটাইটেল আছে \"এখানে একটি সাবটাইটেল\"", "LabelSettingsPreferMatchedMetadata": "মিলিত মেটাডেটা পছন্দ করুন", @@ -514,7 +539,12 @@ "LabelSettingsStoreMetadataWithItem": "আইটেমের সাথে মেটাডেটা সংরক্ষণ করুন", "LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে", "LabelSettingsTimeFormat": "সময় বিন্যাস", + "LabelShare": "শেয়ার করুন", + "LabelShareOpen": "শেয়ার খোলা", + "LabelShareURL": "শেয়ার ইউআরএল", "LabelShowAll": "সব দেখান", + "LabelShowSeconds": "সেকেন্ড দেখান", + "LabelShowSubtitles": "সহ-শিরোনাম দেখান", "LabelSize": "আকার", "LabelSleepTimer": "স্লিপ টাইমার", "LabelSlug": "স্লাগ", @@ -552,6 +582,10 @@ "LabelThemeDark": "অন্ধকার", "LabelThemeLight": "আলো", "LabelTimeBase": "সময় বেস", + "LabelTimeDurationXHours": "{0} ঘণ্টা", + "LabelTimeDurationXMinutes": "{0} মিনিট", + "LabelTimeDurationXSeconds": "{0} সেকেন্ড", + "LabelTimeInMinutes": "মিনিটে সময়", "LabelTimeListened": "সময় শোনা হয়েছে", "LabelTimeListenedToday": "আজ শোনার সময়", "LabelTimeRemaining": "{0}টি অবশিষ্ট", @@ -575,6 +609,7 @@ "LabelUnabridged": "অসংলগ্ন", "LabelUndo": "পূর্বাবস্থা", "LabelUnknown": "অজানা", + "LabelUnknownPublishDate": "প্রকাশের তারিখ অজানা", "LabelUpdateCover": "কভার আপডেট করুন", "LabelUpdateCoverHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান কভারগুলি ওভাররাইট করার অনুমতি দিন", "LabelUpdateDetails": "বিশদ আপডেট করুন", @@ -591,9 +626,12 @@ "LabelVersion": "সংস্করণ", "LabelViewBookmarks": "বুকমার্ক দেখুন", "LabelViewChapters": "অধ্যায় দেখুন", + "LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন", "LabelViewQueue": "প্লেয়ার সারি দেখুন", "LabelVolume": "ভলিউম", "LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন", + "LabelXBooks": "{0}টি বই", + "LabelXItems": "{0}টি আইটেম", "LabelYearReviewHide": "পর্যালোচনার বছর লুকান", "LabelYearReviewShow": "পর্যালোচনার বছর দেখুন", "LabelYourAudiobookDuration": "আপনার অডিওবুকের সময়কাল", @@ -601,12 +639,16 @@ "LabelYourPlaylists": "আপনার প্লেলিস্ট", "LabelYourProgress": "আপনার অগ্রগতি", "MessageAddToPlayerQueue": "প্লেয়ার সারিতে যোগ করুন", - "MessageAppriseDescription": "এই বৈশিষ্ট্যটি ব্যবহার করার জন্য আপনাকে এর একটি উদাহরণ থাকতে হবে চলমান বা একটি এপিআই যা সেই একই অনুরোধগুলি পরিচালনা করবে৷
বিজ্ঞপ্তি পাঠানোর জন্য Apprise API Url সম্পূর্ণ URL পাথ হওয়া উচিত, যেমন, যদি আপনার API উদাহরণ http://192.168 এ পরিবেশিত হয়৷ 1.1:8337 তারপর আপনি http://192.168.1.1:8337/notify লিখবেন।", + "MessageAppriseDescription": "এই বৈশিষ্ট্যটি ব্যবহার করার জন্য আপনাকে Apprise API চালানোর একটি উদাহরণ বা একটি এপিআই পরিচালনা করতে হবে যে একই অনুরোধ পরিচালনা করবে।
অ্যাপ্রাইজ এপিআই ইউআরএলটি বিজ্ঞপ্তি পাঠানোর জন্য সম্পূর্ণ ইউআরএল পথ হওয়া উচিত, যেমন, যদি আপনার API ইনস্ট্যান্স http://192.168.1.1:8337 এ পরিবেশিত হয় তাহলে আপনি রাখবেন >http://192.168.1.1:8337/notify।", "MessageBackupsDescription": "ব্যাকআপের মধ্যে রয়েছে ব্যবহারকারী, ব্যবহারকারীর অগ্রগতি, লাইব্রেরি আইটেমের বিবরণ, সার্ভার সেটিংস এবং /metadata/items & /metadata/authors-এ সংরক্ষিত ছবি। ব্যাকআপগুলি আপনার লাইব্রেরি ফোল্ডারে সঞ্চিত কোনো ফাইল >অন্তর্ভুক্ত করবেন না।", + "MessageBackupsLocationEditNote": "দ্রষ্টব্য: ব্যাকআপ অবস্থান আপডেট করলে বিদ্যমান ব্যাকআপগুলি সরানো বা সংশোধন করা হবে না", + "MessageBackupsLocationNoEditNote": "দ্রষ্টব্য: ব্যাকআপ অবস্থান একটি পরিবেশ পরিবর্তনশীল মাধ্যমে স্থির করা হয়েছে এবং এখানে পরিবর্তন করা যাবে না।", + "MessageBackupsLocationPathEmpty": "ব্যাকআপ অবস্থানের পথ খালি থাকতে পারবে না", "MessageBatchQuickMatchDescription": "কুইক ম্যাচ নির্বাচিত আইটেমগুলির জন্য অনুপস্থিত কভার এবং মেটাডেটা যোগ করার চেষ্টা করবে। বিদ্যমান কভার এবং/অথবা মেটাডেটা ওভাররাইট করার জন্য দ্রুত ম্যাচকে অনুমতি দিতে নীচের বিকল্পগুলি সক্ষম করুন।", "MessageBookshelfNoCollections": "আপনি এখনও কোনো সংগ্রহ করেননি", "MessageBookshelfNoRSSFeeds": "কোনও RSS ফিড খোলা নেই", "MessageBookshelfNoResultsForFilter": "ফিল্টার \"{0}: {1}\" এর জন্য কোন ফলাফল নেই", + "MessageBookshelfNoResultsForQuery": "প্রশ্নের জন্য কোন ফলাফল নেই", "MessageBookshelfNoSeries": "আপনার কোনো সিরিজ নেই", "MessageChapterEndIsAfter": "অধ্যায়ের সমাপ্তি আপনার অডিওবুকের শেষে", "MessageChapterErrorFirstNotZero": "প্রথম অধ্যায় 0 এ শুরু হতে হবে", @@ -616,16 +658,24 @@ "MessageCheckingCron": "ক্রন পরীক্ষা করা হচ্ছে...", "MessageConfirmCloseFeed": "আপনি কি নিশ্চিত যে আপনি এই ফিডটি বন্ধ করতে চান?", "MessageConfirmDeleteBackup": "আপনি কি নিশ্চিত যে আপনি {0} এর ব্যাকআপ মুছে ফেলতে চান?", + "MessageConfirmDeleteDevice": "আপনি কি নিশ্চিতভাবে ই-রিডার ডিভাইস \"{0}\" মুছতে চান?", "MessageConfirmDeleteFile": "এটি আপনার ফাইল সিস্টেম থেকে ফাইলটি মুছে দেবে। আপনি কি নিশ্চিত?", "MessageConfirmDeleteLibrary": "আপনি কি নিশ্চিত যে আপনি স্থায়ীভাবে লাইব্রেরি \"{0}\" মুছে ফেলতে চান?", "MessageConfirmDeleteLibraryItem": "এটি ডাটাবেস এবং আপনার ফাইল সিস্টেম থেকে লাইব্রেরি আইটেমটি মুছে ফেলবে। আপনি কি নিশ্চিত?", "MessageConfirmDeleteLibraryItems": "এটি ডাটাবেস এবং আপনার ফাইল সিস্টেম থেকে {0}টি লাইব্রেরি আইটেম মুছে ফেলবে। আপনি কি নিশ্চিত?", + "MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?", + "MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?", "MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?", "MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?", "MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?", "MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?", + "MessageConfirmMarkItemFinished": "আপনি কি \"{0}\" কে সমাপ্ত হিসাবে চিহ্নিত করার বিষয়ে নিশ্চিত?", + "MessageConfirmMarkItemNotFinished": "আপনি কি \"{0}\" শেষ হয়নি বলে চিহ্নিত করার বিষয়ে নিশ্চিত?", "MessageConfirmMarkSeriesFinished": "আপনি কি নিশ্চিত যে আপনি এই সিরিজের সমস্ত বইকে সমাপ্ত হিসাবে চিহ্নিত করতে চান?", "MessageConfirmMarkSeriesNotFinished": "আপনি কি নিশ্চিত যে আপনি এই সিরিজের সমস্ত বইকে শেষ হয়নি বলে চিহ্নিত করতে চান?", + "MessageConfirmNotificationTestTrigger": "পরীক্ষার তথ্য দিয়ে এই বিজ্ঞপ্তিটি ট্রিগার করবেন?", + "MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক /metadata/cache-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।

আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?", + "MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক /metadata/cache/items-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।
আপনি কি নিশ্চিত?", "MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে।

আপনি কি চালিয়ে যেতে চান?", "MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?", "MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?", @@ -642,12 +692,15 @@ "MessageConfirmRenameTag": "আপনি কি সব আইটেমের জন্য \"{0}\" ট্যাগের নাম পরিবর্তন করে \"{1}\" করার বিষয়ে নিশ্চিত?", "MessageConfirmRenameTagMergeNote": "দ্রষ্টব্য: এই ট্যাগটি আগে থেকেই বিদ্যমান তাই সেগুলিকে একত্র করা হবে।", "MessageConfirmRenameTagWarning": "সতর্কতা! একটি ভিন্ন কেসিং সহ একটি অনুরূপ ট্যাগ ইতিমধ্যেই বিদ্যমান \"{0}\"।", + "MessageConfirmResetProgress": "আপনি কি আপনার অগ্রগতি রিসেট করার বিষয়ে নিশ্চিত?", "MessageConfirmSendEbookToDevice": "আপনি কি নিশ্চিত যে আপনি \"{2}\" ডিভাইসে {0} ইবুক \"{1}\" পাঠাতে চান?", + "MessageConfirmUnlinkOpenId": "আপনি কি এই ব্যবহারকারীকে ওপেনআইডি থেকে লিঙ্কমুক্ত করার বিষয়ে নিশ্চিত?", "MessageDownloadingEpisode": "ডাউনলোডিং পর্ব", "MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন", + "MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!", "MessageEmbedFinished": "এম্বেড করা শেষ!", "MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ", - "MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below।", + "MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।", "MessageFeedURLWillBe": "ফিড URL হবে {0}", "MessageFetching": "আনয় হচ্ছে...", "MessageForceReScanDescription": "সকল ফাইল আবার নতুন স্ক্যানের মত স্ক্যান করবে। অডিও ফাইল ID3 ট্যাগ, OPF ফাইল, এবং টেক্সট ফাইলগুলি নতুন হিসাবে স্ক্যান করা হবে।", @@ -659,7 +712,7 @@ "MessageListeningSessionsInTheLastYear": "গত বছরে {0}টি শোনার সেশন", "MessageLoading": "লোড হচ্ছে...", "MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...", - "MessageLogsDescription": "Logs are stored in /metadata/logs as JSON files. Crash logs are stored in /metadata/logs/crash_logs.txt।", + "MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে /metadata/logs-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি /metadata/logs/crash_logs.txt-এ সংরক্ষণ করা হয়।", "MessageM4BFailed": "M4B ব্যর্থ!", "MessageM4BFinished": "M4B সমাপ্ত!", "MessageMapChapterTitles": "টাইমস্ট্যাম্প সামঞ্জস্য না করে আপনার বিদ্যমান অডিওবুক অধ্যায়গুলিতে অধ্যায়ের শিরোনাম ম্যাপ করুন", @@ -676,6 +729,7 @@ "MessageNoCollections": "কোন সংগ্রহ নেই", "MessageNoCoversFound": "কোন কভার পাওয়া যায়নি", "MessageNoDescription": "কোন বর্ণনা নেই", + "MessageNoDevices": "কোনো ডিভাইস নেই", "MessageNoDownloadsInProgress": "বর্তমানে কোনো ডাউনলোড চলছে না", "MessageNoDownloadsQueued": "কোনও ডাউনলোড সারি নেই", "MessageNoEpisodeMatchesFound": "কোন পর্বের মিল পাওয়া যায়নি", @@ -698,10 +752,12 @@ "MessageNoUpdatesWereNecessary": "কোন আপডেটের প্রয়োজন ছিল না", "MessageNoUserPlaylists": "আপনার কোনো প্লেলিস্ট নেই", "MessageNotYetImplemented": "এখনও বাস্তবায়িত হয়নি", + "MessageOpmlPreviewNote": "দ্রষ্টব্য: এটি পার্স করা OPML ফাইলের একটি পূর্বরূপ। প্রকৃত পডকাস্ট শিরোনাম RSS ফিড থেকে নেওয়া হবে।", "MessageOr": "বা", "MessagePauseChapter": "পজ অধ্যায় প্লেব্যাক", "MessagePlayChapter": "অধ্যায়ের শুরুতে শুনুন", "MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন", + "MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।", "MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই", "MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।", "MessageRemoveChapter": "অধ্যায় সরান", @@ -716,6 +772,9 @@ "MessageSelected": "{0}টি নির্বাচিত", "MessageServerCouldNotBeReached": "সার্ভারে পৌঁছানো যায়নি", "MessageSetChaptersFromTracksDescription": "প্রতিটি অডিও ফাইলকে অধ্যায় হিসেবে ব্যবহার করে অধ্যায় সেট করুন এবং অডিও ফাইলের নাম হিসেবে অধ্যায়ের শিরোনাম করুন", + "MessageShareExpirationWillBe": "মেয়াদ শেষ হবে {0}", + "MessageShareExpiresIn": "মেয়াদ শেষ হবে {0}", + "MessageShareURLWillBe": "শেয়ার করা ইউআরএল হবে {0}", "MessageStartPlaybackAtTime": "\"{0}\" এর জন্য {1} এ প্লেব্যাক শুরু করবেন?", "MessageThinking": "চিন্তা করছি...", "MessageUploaderItemFailed": "আপলোড করতে ব্যর্থ", @@ -739,20 +798,48 @@ "PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম", "PlaceholderSearch": "অনুসন্ধান..", "PlaceholderSearchEpisode": "অনুসন্ধান পর্ব..", + "StatsAuthorsAdded": "লেখক যোগ করা হয়েছে", + "StatsBooksAdded": "বই যোগ করা হয়েছে", + "StatsBooksAdditional": "কিছু সংযোজনের মধ্যে রয়েছে…", + "StatsBooksFinished": "বই সমাপ্ত", + "StatsBooksFinishedThisYear": "এ বছর শেষ হওয়া কিছু বই …", + "StatsBooksListenedTo": "বই শোনা হয়েছে", + "StatsCollectionGrewTo": "আপনার বইয়ের সংগ্রহ বেড়েছে…", + "StatsSessions": "অধিবেশনসমূহ", + "StatsSpentListening": "শুনে কাটিয়েছেন", + "StatsTopAuthor": "শীর্ষস্থানীয় লেখক", + "StatsTopAuthors": "শীর্ষস্থানীয় লেখকগণ", + "StatsTopGenre": "শীর্ষ ঘরানা", + "StatsTopGenres": "শীর্ষ ঘরানাগুলো", + "StatsTopMonth": "সেরা মাস", + "StatsTopNarrator": "শীর্ষ কথক", + "StatsTopNarrators": "শীর্ষ কথকগণ", + "StatsTotalDuration": "মোট সময়কাল…", + "StatsYearInReview": "বাৎসরিক পর্যালোচনা", "ToastAccountUpdateFailed": "অ্যাকাউন্ট আপডেট করতে ব্যর্থ", "ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে", + "ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে", "ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে", + "ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি", + "ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে", + "ToastAuthorSearchNotFound": "লেখক পাওয়া যায়নি", "ToastAuthorUpdateFailed": "লেখক আপডেট করতে ব্যর্থ", "ToastAuthorUpdateMerged": "লেখক একত্রিত হয়েছে", "ToastAuthorUpdateSuccess": "লেখক আপডেট করেছেন", "ToastAuthorUpdateSuccessNoImageFound": "লেখক আপডেট করেছেন (কোন ছবি পাওয়া যায়নি)", + "ToastBackupAppliedSuccess": "ব্যাকআপ প্রয়োগ করা হয়েছে", "ToastBackupCreateFailed": "ব্যাকআপ তৈরি করতে ব্যর্থ", "ToastBackupCreateSuccess": "ব্যাকআপ তৈরি করা হয়েছে", "ToastBackupDeleteFailed": "ব্যাকআপ মুছে ফেলতে ব্যর্থ", "ToastBackupDeleteSuccess": "ব্যাকআপ মুছে ফেলা হয়েছে", + "ToastBackupInvalidMaxKeep": "রাখার জন্য অকার্যকর ব্যাকআপের সংখ্যা", + "ToastBackupInvalidMaxSize": "অকার্যকর সর্বোচ্চ ব্যাকআপ আকার", + "ToastBackupPathUpdateFailed": "ব্যাকআপ পথ আপডেট করতে ব্যর্থ হয়েছে", "ToastBackupRestoreFailed": "ব্যাকআপ পুনরুদ্ধার করতে ব্যর্থ", "ToastBackupUploadFailed": "ব্যাকআপ আপলোড করতে ব্যর্থ", "ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে", + "ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে", + "ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে", "ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে", "ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য", "ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ", @@ -760,20 +847,50 @@ "ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে", "ToastBookmarkUpdateFailed": "বুকমার্ক আপডেট করতে ব্যর্থ", "ToastBookmarkUpdateSuccess": "বুকমার্ক আপডেট করা হয়েছে", + "ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে", + "ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে", "ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে", "ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে", + "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", + "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", + "ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে", "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে", "ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে", "ToastCollectionUpdateFailed": "সংগ্রহ আপডেট করতে ব্যর্থ", "ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে", + "ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে", + "ToastDeleteFileFailed": "ফাইল মুছে ফেলতে ব্যর্থ হয়েছে", + "ToastDeleteFileSuccess": "ফাইল মুছে ফেলা হয়েছে", + "ToastDeviceAddFailed": "ডিভাইস যোগ করতে ব্যর্থ হয়েছে", + "ToastDeviceNameAlreadyExists": "এই নামের ইরিডার ডিভাইস ইতিমধ্যেই বিদ্যমান", + "ToastDeviceTestEmailFailed": "পরীক্ষামূলক ইমেল পাঠাতে ব্যর্থ হয়েছে", + "ToastDeviceTestEmailSuccess": "পরীক্ষামূলক ইমেল পাঠানো হয়েছে", + "ToastDeviceUpdateFailed": "ডিভাইস আপডেট করতে ব্যর্থ হয়েছে", + "ToastEmailSettingsUpdateFailed": "ইমেল সেটিংস আপডেট করতে ব্যর্থ হয়েছে", + "ToastEmailSettingsUpdateSuccess": "ইমেল সেটিংস আপডেট করা হয়েছে", + "ToastEncodeCancelFailed": "এনকোড বাতিল করতে ব্যর্থ হয়েছে", + "ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে", + "ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে", + "ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে", + "ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না", + "ToastFailedToLoadData": "ডেটা লোড করা যায়নি", + "ToastFailedToShare": "শেয়ার করতে ব্যর্থ", + "ToastFailedToUpdateAccount": "অ্যাকাউন্ট আপডেট করতে ব্যর্থ", + "ToastFailedToUpdateUser": "ব্যবহারকারী আপডেট করতে ব্যর্থ", + "ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল", + "ToastInvalidUrl": "অকার্যকর ইউআরএল", "ToastItemCoverUpdateFailed": "আইটেম কভার আপডেট করতে ব্যর্থ হয়েছে", "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", + "ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ", + "ToastItemDeletedSuccess": "মুছে ফেলা আইটেম", "ToastItemDetailsUpdateFailed": "আইটেমের বিবরণ আপডেট করতে ব্যর্থ", "ToastItemDetailsUpdateSuccess": "আইটেমের বিবরণ আপডেট করা হয়েছে", "ToastItemMarkedAsFinishedFailed": "সমাপ্ত হিসাবে চিহ্নিত করতে ব্যর্থ", "ToastItemMarkedAsFinishedSuccess": "আইটেম সমাপ্ত হিসাবে চিহ্নিত", "ToastItemMarkedAsNotFinishedFailed": "সমাপ্ত হয়নি হিসাবে চিহ্নিত করতে ব্যর্থ", "ToastItemMarkedAsNotFinishedSuccess": "আইটেম সমাপ্ত হয়নি বলে চিহ্নিত", + "ToastItemUpdateFailed": "আইটেম আপডেট করতে ব্যর্থ", + "ToastItemUpdateSuccess": "আইটেম আপডেট করা হয়েছে", "ToastLibraryCreateFailed": "লাইব্রেরি তৈরি করতে ব্যর্থ", "ToastLibraryCreateSuccess": "লাইব্রেরি \"{0}\" তৈরি করা হয়েছে", "ToastLibraryDeleteFailed": "লাইব্রেরি মুছে ফেলতে ব্যর্থ", @@ -782,6 +899,25 @@ "ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে", "ToastLibraryUpdateFailed": "লাইব্রেরি আপডেট করতে ব্যর্থ", "ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে", + "ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক", + "ToastNameRequired": "নাম আবশ্যক", + "ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"", + "ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে", + "ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে", + "ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে", + "ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে", + "ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন", + "ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই", + "ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ", + "ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ", + "ToastNotificationFailedMaximum": "সর্বাধিক ব্যর্থ প্রচেষ্টা >= 0 হতে হবে", + "ToastNotificationQueueMaximum": "সর্বাধিক বিজ্ঞপ্তি সারি >= 0 হতে হবে", + "ToastNotificationSettingsUpdateFailed": "বিজ্ঞপ্তি সেটিংস আপডেট করতে ব্যর্থ", + "ToastNotificationSettingsUpdateSuccess": "বিজ্ঞপ্তি সেটিংস আপডেট করা হয়েছে", + "ToastNotificationTestTriggerFailed": "পরীক্ষামূলক বিজ্ঞপ্তি ট্রিগার করতে ব্যর্থ হয়েছে", + "ToastNotificationTestTriggerSuccess": "পরীক্ষামুলক বিজ্ঞপ্তি ট্রিগার হয়েছে", + "ToastNotificationUpdateFailed": "বিজ্ঞপ্তি আপডেট করতে ব্যর্থ", + "ToastNotificationUpdateSuccess": "বিজ্ঞপ্তি আপডেট হয়েছে", "ToastPlaylistCreateFailed": "প্লেলিস্ট তৈরি করতে ব্যর্থ", "ToastPlaylistCreateSuccess": "প্লেলিস্ট তৈরি করা হয়েছে", "ToastPlaylistRemoveSuccess": "প্লেলিস্ট সরানো হয়েছে", @@ -789,19 +925,52 @@ "ToastPlaylistUpdateSuccess": "প্লেলিস্ট আপডেট করা হয়েছে", "ToastPodcastCreateFailed": "পডকাস্ট তৈরি করতে ব্যর্থ", "ToastPodcastCreateSuccess": "পডকাস্ট সফলভাবে তৈরি করা হয়েছে", + "ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে", + "ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি", + "ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই", + "ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে", + "ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে", + "ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক", + "ToastProviderRemoveSuccess": "প্রদানকারী সরানো হয়েছে", "ToastRSSFeedCloseFailed": "RSS ফিড বন্ধ করতে ব্যর্থ", "ToastRSSFeedCloseSuccess": "RSS ফিড বন্ধ", + "ToastRemoveFailed": "মুছে ফেলতে ব্যর্থ হয়েছে", "ToastRemoveItemFromCollectionFailed": "সংগ্রহ থেকে আইটেম সরাতে ব্যর্থ", "ToastRemoveItemFromCollectionSuccess": "সংগ্রহ থেকে আইটেম সরানো হয়েছে", + "ToastRemoveItemsWithIssuesFailed": "সমস্যাযুক্ত লাইব্রেরি আইটেমগুলি সরাতে ব্যর্থ হয়েছে", + "ToastRemoveItemsWithIssuesSuccess": "সমস্যাযুক্ত লাইব্রেরি আইটেম সরানো হয়েছে", + "ToastRenameFailed": "পুনঃনামকরণ ব্যর্থ হয়েছে", + "ToastRescanFailed": "{0} এর জন্য পুনরায় স্ক্যান করা ব্যর্থ হয়েছে", + "ToastRescanRemoved": "পুনরায় স্ক্যান সম্পূর্ণ,আইটেম সরানো হয়েছে", + "ToastRescanUpToDate": "পুনরায় স্ক্যান সম্পূর্ণ, আইটেম সাম্প্রতিক ছিল", + "ToastRescanUpdated": "পুনরায় স্ক্যান সম্পূর্ণ, আইটেম আপডেট করা হয়েছে", + "ToastScanFailed": "লাইব্রেরি আইটেম স্ক্যান করতে ব্যর্থ হয়েছে", + "ToastSelectAtLeastOneUser": "অন্তত একজন ব্যবহারকারী নির্বাচন করুন", "ToastSendEbookToDeviceFailed": "ডিভাইসে ইবুক পাঠাতে ব্যর্থ", "ToastSendEbookToDeviceSuccess": "ইবুক \"{0}\" ডিভাইসে পাঠানো হয়েছে", "ToastSeriesUpdateFailed": "সিরিজ আপডেট ব্যর্থ হয়েছে", "ToastSeriesUpdateSuccess": "সিরিজ আপডেট সাফল্য", + "ToastServerSettingsUpdateFailed": "সার্ভার সেটিংস আপডেট করতে ব্যর্থ হয়েছে", + "ToastServerSettingsUpdateSuccess": "সার্ভার সেটিংস আপডেট করা হয়েছে", + "ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে", "ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ", "ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে", + "ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে", + "ToastSlugRequired": "স্লাগ আবশ্যক", "ToastSocketConnected": "সকেট সংযুক্ত", "ToastSocketDisconnected": "সকেট সংযোগ বিচ্ছিন্ন", "ToastSocketFailedToConnect": "সকেট সংযোগ করতে ব্যর্থ হয়েছে", + "ToastSortingPrefixesEmptyError": "কমপক্ষে ১ টি সাজানোর উপসর্গ থাকতে হবে", + "ToastSortingPrefixesUpdateFailed": "বাছাই উপসর্গ আপডেট করতে ব্যর্থ হয়েছে", + "ToastSortingPrefixesUpdateSuccess": "বাছাই করা উপসর্গ আপডেট করা হয়েছে ({0}টি আইটেম)", + "ToastTitleRequired": "শিরোনাম আবশ্যক", + "ToastUnknownError": "অজানা ত্রুটি", + "ToastUnlinkOpenIdFailed": "OpenID থেকে ব্যবহারকারীকে আনলিঙ্ক করতে ব্যর্থ হয়েছে", + "ToastUnlinkOpenIdSuccess": "OpenID থেকে ব্যবহারকারীকে লিঙ্কমুক্ত করা হয়েছে", "ToastUserDeleteFailed": "ব্যবহারকারী মুছতে ব্যর্থ", - "ToastUserDeleteSuccess": "ব্যবহারকারী মুছে ফেলা হয়েছে" + "ToastUserDeleteSuccess": "ব্যবহারকারী মুছে ফেলা হয়েছে", + "ToastUserPasswordChangeSuccess": "পাসওয়ার্ড সফলভাবে পরিবর্তন করা হয়েছে", + "ToastUserPasswordMismatch": "পাসওয়ার্ড মিলছে না", + "ToastUserPasswordMustChange": "নতুন পাসওয়ার্ড পুরানো পাসওয়ার্ডের সাথে মিলতে পারবে না", + "ToastUserRootRequireName": "একটি রুট ব্যবহারকারীর নাম লিখতে হবে" } From 1ec23a5699dacf027e8a722dee42f3df726d75f1 Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 9 Sep 2024 14:12:16 +0000 Subject: [PATCH 084/539] Translated using Weblate (German) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/de.json b/client/strings/de.json index b7eed8b972..4a7e6ce9ce 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -98,6 +98,7 @@ "ButtonStats": "Statistiken", "ButtonSubmit": "Ok", "ButtonTest": "Test", + "ButtonUnlinkOpenId": "OpenID trennen", "ButtonUpload": "Hochladen", "ButtonUploadBackup": "Sicherung hochladen", "ButtonUploadCover": "Titelbild hochladen", From ce213c3d89458baeb77324ce59a5f2137740564e Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 9 Sep 2024 16:15:44 -0500 Subject: [PATCH 085/539] Version bump v2.13.4 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 83586a4e0a..c4a02c4cdb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.13.3", + "version": "2.13.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.13.3", + "version": "2.13.4", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index e33e4ac890..8f65c493cd 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.13.3", + "version": "2.13.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index eada191873..9fd0648d64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.13.3", + "version": "2.13.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.13.3", + "version": "2.13.4", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 9ad9cc9435..53c8e36740 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.13.3", + "version": "2.13.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 7cbf9de8ca4c5f4fb561a0f7468195dd7ae14ecf Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 10 Sep 2024 15:57:07 -0500 Subject: [PATCH 086/539] Update migrations jsdocs --- server/managers/MigrationManager.js | 16 ++++++++++++++++ .../migrations/v0.0.1-migration_example.js | 15 +++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index 8f08cf8aae..53db461bfb 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -9,6 +9,10 @@ const Logger = require('../Logger') class MigrationManager { static MIGRATIONS_META_TABLE = 'migrationsMeta' + /** + * @param {import('../Database').sequelize} sequelize + * @param {string} [configPath] + */ constructor(sequelize, configPath = global.configPath) { if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.') this.sequelize = sequelize @@ -23,6 +27,11 @@ class MigrationManager { this.umzug = null } + /** + * Init version vars and copy migration files to config dir if necessary + * + * @param {string} serverVersion + */ async init(serverVersion) { if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`) @@ -212,6 +221,13 @@ class MigrationManager { ) } + /** + * + * @param {{ name: string }[]} migrations + * @param {string[]} executedMigrations - names of executed migrations + * @param {string} direction - 'up' or 'down' + * @returns {string[]} - names of migrations to run + */ findMigrationsToRun(migrations, executedMigrations, direction) { const migrationsToRun = migrations .filter((migration) => { diff --git a/test/server/migrations/v0.0.1-migration_example.js b/test/server/migrations/v0.0.1-migration_example.js index a000de07a4..5af66fc43c 100644 --- a/test/server/migrations/v0.0.1-migration_example.js +++ b/test/server/migrations/v0.0.1-migration_example.js @@ -1,10 +1,18 @@ const { DataTypes } = require('sequelize') +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + /** * This is an example of an upward migration script. * - * @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object. - * @param {import { Logger } from "../../../server/Logger";} options.context.logger - a Logger object. + * @param {MigrationOptions} options - an object containing the migration context. * @returns {Promise} - A promise that resolves when the migration is complete. */ async function up({ context: { queryInterface, logger } }) { @@ -28,8 +36,7 @@ async function up({ context: { queryInterface, logger } }) { /** * This is an example of a downward migration script. * - * @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object. - * @param {import { Logger } from "../../../server/Logger";} options.context.logger - a Logger object. + * @param {MigrationOptions} options - an object containing the migration context. * @returns {Promise} - A promise that resolves when the migration is complete. */ async function down({ context: { queryInterface, logger } }) { From 682a99dd439230f28b3be990026f4e4d4e447853 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 11 Sep 2024 19:58:30 +0300 Subject: [PATCH 087/539] Log non-strings into log file like console.log does --- server/Logger.js | 61 ++++---- test/server/Logger.test.js | 285 +++++++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+), 29 deletions(-) create mode 100644 test/server/Logger.test.js diff --git a/server/Logger.js b/server/Logger.js index 3e38f0fd5c..b10c45b7d0 100644 --- a/server/Logger.js +++ b/server/Logger.js @@ -1,5 +1,6 @@ const date = require('./libs/dateAndTime') const { LogLevel } = require('./utils/constants') +const util = require('util') class Logger { constructor() { @@ -69,15 +70,17 @@ class Logger { /** * * @param {number} level + * @param {string} levelName * @param {string[]} args * @param {string} src */ - async handleLog(level, args, src) { + async #logToFileAndListeners(level, levelName, args, src) { + const expandedArgs = args.map((arg) => (typeof arg !== 'string' ? util.inspect(arg) : arg)) const logObj = { timestamp: this.timestamp, source: src, - message: args.join(' '), - levelName: this.getLogLevelString(level), + message: expandedArgs.join(' '), + levelName, level } @@ -89,7 +92,7 @@ class Logger { }) // Save log to file - if (level >= this.logLevel) { + if (level >= LogLevel.FATAL || level >= this.logLevel) { await this.logManager?.logToFile(logObj) } } @@ -99,50 +102,50 @@ class Logger { this.debug(`Set Log Level to ${this.levelString}`) } + static ConsoleMethods = { + TRACE: 'trace', + DEBUG: 'debug', + INFO: 'info', + WARN: 'warn', + ERROR: 'error', + FATAL: 'error', + NOTE: 'log' + } + + #log(levelName, source, ...args) { + const level = LogLevel[levelName] + if (level < LogLevel.FATAL && level < this.logLevel) return + const consoleMethod = Logger.ConsoleMethods[levelName] + console[consoleMethod](`[${this.timestamp}] ${levelName}:`, ...args) + this.#logToFileAndListeners(level, levelName, args, source) + } + trace(...args) { - if (this.logLevel > LogLevel.TRACE) return - console.trace(`[${this.timestamp}] TRACE:`, ...args) - this.handleLog(LogLevel.TRACE, args, this.source) + this.#log('TRACE', this.source, ...args) } debug(...args) { - if (this.logLevel > LogLevel.DEBUG) return - console.debug(`[${this.timestamp}] DEBUG:`, ...args, `(${this.source})`) - this.handleLog(LogLevel.DEBUG, args, this.source) + this.#log('DEBUG', this.source, ...args) } info(...args) { - if (this.logLevel > LogLevel.INFO) return - console.info(`[${this.timestamp}] INFO:`, ...args) - this.handleLog(LogLevel.INFO, args, this.source) + this.#log('INFO', this.source, ...args) } warn(...args) { - if (this.logLevel > LogLevel.WARN) return - console.warn(`[${this.timestamp}] WARN:`, ...args, `(${this.source})`) - this.handleLog(LogLevel.WARN, args, this.source) + this.#log('WARN', this.source, ...args) } error(...args) { - if (this.logLevel > LogLevel.ERROR) return - console.error(`[${this.timestamp}] ERROR:`, ...args, `(${this.source})`) - this.handleLog(LogLevel.ERROR, args, this.source) + this.#log('ERROR', this.source, ...args) } - /** - * Fatal errors are ones that exit the process - * Fatal logs are saved to crash_logs.txt - * - * @param {...any} args - */ fatal(...args) { - console.error(`[${this.timestamp}] FATAL:`, ...args, `(${this.source})`) - return this.handleLog(LogLevel.FATAL, args, this.source) + this.#log('FATAL', this.source, ...args) } note(...args) { - console.log(`[${this.timestamp}] NOTE:`, ...args) - this.handleLog(LogLevel.NOTE, args, this.source) + this.#log('NOTE', this.source, ...args) } } module.exports = new Logger() diff --git a/test/server/Logger.test.js b/test/server/Logger.test.js new file mode 100644 index 0000000000..43b8e9afda --- /dev/null +++ b/test/server/Logger.test.js @@ -0,0 +1,285 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const Logger = require('../../server/Logger') // Adjust the path as needed +const { LogLevel } = require('../../server/utils/constants') +const date = require('../../server/libs/dateAndTime') +const util = require('util') + +describe('Logger', function () { + let consoleTraceStub + let consoleDebugStub + let consoleInfoStub + let consoleWarnStub + let consoleErrorStub + let consoleLogStub + + beforeEach(function () { + // Stub the date format function to return a consistent timestamp + sinon.stub(date, 'format').returns('2024-09-10 12:34:56.789') + // Stub the source getter to return a consistent source + sinon.stub(Logger, 'source').get(() => 'some/source.js') + // Stub the console methods used in Logger + consoleTraceStub = sinon.stub(console, 'trace') + consoleDebugStub = sinon.stub(console, 'debug') + consoleInfoStub = sinon.stub(console, 'info') + consoleWarnStub = sinon.stub(console, 'warn') + consoleErrorStub = sinon.stub(console, 'error') + consoleLogStub = sinon.stub(console, 'log') + // Initialize the Logger's logManager as a mock object + Logger.logManager = { + logToFile: sinon.stub().resolves() + } + }) + + afterEach(function () { + sinon.restore() + }) + + describe('logging methods', function () { + it('should have a method for each log level defined in the static block', function () { + const loggerMethods = Object.keys(LogLevel).map((key) => key.toLowerCase()) + + loggerMethods.forEach((method) => { + expect(Logger).to.have.property(method).that.is.a('function') + }) + }) + + it('should call console.trace for trace logging', function () { + // Arrange + Logger.logLevel = LogLevel.TRACE + + // Act + Logger.trace('Test message') + + // Assert + expect(consoleTraceStub.calledOnce).to.be.true + }) + + it('should call console.debug for debug logging', function () { + // Arrange + Logger.logLevel = LogLevel.TRACE + + // Act + Logger.debug('Test message') + + // Assert + expect(consoleDebugStub.calledOnce).to.be.true + }) + + it('should call console.info for info logging', function () { + // Arrange + Logger.logLevel = LogLevel.TRACE + + // Act + Logger.info('Test message') + + // Assert + expect(consoleInfoStub.calledOnce).to.be.true + }) + + it('should call console.warn for warn logging', function () { + // Arrange + Logger.logLevel = LogLevel.TRACE + + // Act + Logger.warn('Test message') + + // Assert + expect(consoleWarnStub.calledOnce).to.be.true + }) + + it('should call console.error for error logging', function () { + // Arrange + Logger.logLevel = LogLevel.TRACE + + // Act + Logger.error('Test message') + + // Assert + expect(consoleErrorStub.calledOnce).to.be.true + }) + + it('should call console.error for fatal logging', function () { + // Arrange + Logger.logLevel = LogLevel.TRACE + + // Act + Logger.fatal('Test message') + + // Assert + expect(consoleErrorStub.calledOnce).to.be.true + }) + + it('should call console.log for note logging', function () { + // Arrange + Logger.logLevel = LogLevel.TRACE + + // Act + Logger.note('Test message') + + // Assert + expect(consoleLogStub.calledOnce).to.be.true + }) + }) + + describe('#log', function () { + it('should log to console and file if level is high enough', async function () { + // Arrange + const logArgs = ['Test message'] + Logger.logLevel = LogLevel.TRACE + + // Act + Logger.debug(...logArgs) + + expect(consoleDebugStub.calledOnce).to.be.true + expect(consoleDebugStub.calledWithExactly('[2024-09-10 12:34:56.789] DEBUG:', ...logArgs)).to.be.true + expect(Logger.logManager.logToFile.calledOnce).to.be.true + expect( + Logger.logManager.logToFile.calledWithExactly({ + timestamp: '2024-09-10 12:34:56.789', + source: 'some/source.js', + message: 'Test message', + levelName: 'DEBUG', + level: LogLevel.DEBUG + }) + ).to.be.true + }) + + it('should not log if log level is too low', function () { + // Arrange + const logArgs = ['This log should not appear'] + // Set log level to ERROR, so DEBUG log should be ignored + Logger.logLevel = LogLevel.ERROR + + // Act + Logger.debug(...logArgs) + + // Verify console.debug is not called + expect(consoleDebugStub.called).to.be.false + expect(Logger.logManager.logToFile.called).to.be.false + }) + + it('should emit log to all connected sockets with appropriate log level', async function () { + // Arrange + const socket1 = { id: '1', emit: sinon.spy() } + const socket2 = { id: '2', emit: sinon.spy() } + Logger.addSocketListener(socket1, LogLevel.DEBUG) + Logger.addSocketListener(socket2, LogLevel.ERROR) + const logArgs = ['Socket test'] + Logger.logLevel = LogLevel.TRACE + + // Act + await Logger.debug(...logArgs) + + // socket1 should receive the log, but not socket2 + expect(socket1.emit.calledOnce).to.be.true + expect( + socket1.emit.calledWithExactly('log', { + timestamp: '2024-09-10 12:34:56.789', + source: 'some/source.js', + message: 'Socket test', + levelName: 'DEBUG', + level: LogLevel.DEBUG + }) + ).to.be.true + + expect(socket2.emit.called).to.be.false + }) + + it('should log fatal messages to console and file regardless of log level', async function () { + // Arrange + const logArgs = ['Fatal error'] + // Set log level to NOTE + 1, so nothing should be logged + Logger.logLevel = LogLevel.NOTE + 1 + + // Act + await Logger.fatal(...logArgs) + + // Assert + expect(consoleErrorStub.calledOnce).to.be.true + expect(consoleErrorStub.calledWithExactly('[2024-09-10 12:34:56.789] FATAL:', ...logArgs)).to.be.true + expect(Logger.logManager.logToFile.calledOnce).to.be.true + expect( + Logger.logManager.logToFile.calledWithExactly({ + timestamp: '2024-09-10 12:34:56.789', + source: 'some/source.js', + message: 'Fatal error', + levelName: 'FATAL', + level: LogLevel.FATAL + }) + ).to.be.true + }) + + it('should log note messages to console and file regardless of log level', async function () { + // Arrange + const logArgs = ['Note message'] + // Set log level to NOTE + 1, so nothing should be logged + Logger.logLevel = LogLevel.NOTE + 1 + + // Act + await Logger.note(...logArgs) + + // Assert + expect(consoleLogStub.calledOnce).to.be.true + expect(consoleLogStub.calledWithExactly('[2024-09-10 12:34:56.789] NOTE:', ...logArgs)).to.be.true + expect(Logger.logManager.logToFile.calledOnce).to.be.true + expect( + Logger.logManager.logToFile.calledWithExactly({ + timestamp: '2024-09-10 12:34:56.789', + source: 'some/source.js', + message: 'Note message', + levelName: 'NOTE', + level: LogLevel.NOTE + }) + ).to.be.true + }) + + it('should log util.inspect(arg) for non-string objects', async function () { + // Arrange + const obj = { key: 'value' } + const logArgs = ['Logging object:', obj] + Logger.logLevel = LogLevel.TRACE + + // Act + await Logger.debug(...logArgs) + + // Assert + expect(consoleDebugStub.calledOnce).to.be.true + expect(consoleDebugStub.calledWithExactly('[2024-09-10 12:34:56.789] DEBUG:', 'Logging object:', obj)).to.be.true + expect(Logger.logManager.logToFile.calledOnce).to.be.true + expect(Logger.logManager.logToFile.firstCall.args[0].message).to.equal('Logging object: ' + util.inspect(obj)) + }) + }) + + describe('socket listeners', function () { + it('should add and remove socket listeners', function () { + // Arrange + const socket1 = { id: '1', emit: sinon.spy() } + const socket2 = { id: '2', emit: sinon.spy() } + + // Act + Logger.addSocketListener(socket1, LogLevel.DEBUG) + Logger.addSocketListener(socket2, LogLevel.ERROR) + Logger.removeSocketListener('1') + + // Assert + expect(Logger.socketListeners).to.have.lengthOf(1) + expect(Logger.socketListeners[0].id).to.equal('2') + }) + }) + + describe('setLogLevel', function () { + it('should change the log level and log the new level', function () { + // Arrange + const debugSpy = sinon.spy(Logger, 'debug') + + // Act + Logger.setLogLevel(LogLevel.WARN) + + // Assert + expect(Logger.logLevel).to.equal(LogLevel.WARN) + expect(debugSpy.calledOnce).to.be.true + expect(debugSpy.calledWithExactly('Set Log Level to WARN')).to.be.true + }) + }) +}) From 220f7ef7cdecd32146a41c707d2e61602b09b645 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 11 Sep 2024 21:40:31 +0300 Subject: [PATCH 088/539] Resolve some weird unrelated flakiness in BookFinder test --- test/server/finders/BookFinder.test.js | 261 +++++++++++-------------- 1 file changed, 119 insertions(+), 142 deletions(-) diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index 03f81f124c..c986cc986a 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -22,7 +22,7 @@ describe('TitleCandidates', () => { }) describe('single add', () => { - [ + ;[ ['adds candidate', 'anna karenina', ['anna karenina']], ['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']], ['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']], @@ -40,23 +40,27 @@ describe('TitleCandidates', () => { ['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], ['does not add empty candidate', '', []], ['does not add spaces-only candidate', ' ', []], - ['does not add empty variant', '1984', ['1984']], - ].forEach(([name, title, expected]) => it(name, () => { - titleCandidates.add(title) - expect(titleCandidates.getCandidates()).to.deep.equal(expected) - })) + ['does not add empty variant', '1984', ['1984']] + ].forEach(([name, title, expected]) => + it(name, () => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + }) + ) }) describe('multiple adds', () => { - [ + ;[ ['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']], ['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], ['orders by position', ['title2', 'title1'], ['title2', 'title1']], - ['dedupes candidates', ['title1', 'title1'], ['title1']], - ].forEach(([name, titles, expected]) => it(name, () => { - for (const title of titles) titleCandidates.add(title) - expect(titleCandidates.getCandidates()).to.deep.equal(expected) - })) + ['dedupes candidates', ['title1', 'title1'], ['title1']] + ].forEach(([name, titles, expected]) => + it(name, () => { + for (const title of titles) titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + }) + ) }) }) @@ -69,12 +73,12 @@ describe('TitleCandidates', () => { }) describe('single add', () => { - [ - ['adds a candidate', 'leo tolstoy', ['leo tolstoy']], - ].forEach(([name, title, expected]) => it(name, () => { - titleCandidates.add(title) - expect(titleCandidates.getCandidates()).to.deep.equal(expected) - })) + ;[['adds a candidate', 'leo tolstoy', ['leo tolstoy']]].forEach(([name, title, expected]) => + it(name, () => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + }) + ) }) }) }) @@ -82,11 +86,7 @@ describe('TitleCandidates', () => { describe('AuthorCandidates', () => { let authorCandidates const audnexus = { - authorASINsRequest: sinon.stub().resolves([ - { name: 'Leo Tolstoy' }, - { name: 'Nikolai Gogol' }, - { name: 'J. K. Rowling' }, - ]), + authorASINsRequest: sinon.stub().resolves([{ name: 'Leo Tolstoy' }, { name: 'Nikolai Gogol' }, { name: 'J. K. Rowling' }]) } describe('cleanAuthor is null', () => { @@ -95,15 +95,15 @@ describe('AuthorCandidates', () => { }) describe('no adds', () => { - [ - ['returns empty author candidate', []], - ].forEach(([name, expected]) => it(name, async () => { - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ;[['returns empty author candidate', []]].forEach(([name, expected]) => + it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) describe('single add', () => { - [ + ;[ ['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']], ['does not add unrecognized candidate', 'fyodor dostoevsky', []], ['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']], @@ -112,21 +112,25 @@ describe('AuthorCandidates', () => { ['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []], ['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], ['adds normalized recognized candidate (et al removed)', 'nikolai gogol et al.', ['nikolai gogol']], - ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']], - ].forEach(([name, author, expected]) => it(name, async () => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']] + ].forEach(([name, author, expected]) => + it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) describe('multi add', () => { - [ + ;[ ['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']], - ['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']], - ].forEach(([name, authors, expected]) => it(name, async () => { - for (const author of authors) authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']] + ].forEach(([name, authors, expected]) => + it(name, async () => { + for (const author of authors) authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) }) @@ -138,21 +142,23 @@ describe('AuthorCandidates', () => { }) describe('no adds', () => { - [ - ['adds cleanAuthor as candidate', [cleanAuthor]], - ].forEach(([name, expected]) => it(name, async () => { - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ;[['adds cleanAuthor as candidate', [cleanAuthor]]].forEach(([name, expected]) => + it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) describe('single add', () => { - [ + ;[ ['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']], - ['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]], - ].forEach(([name, author, expected]) => it(name, async () => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]] + ].forEach(([name, author, expected]) => + it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) }) @@ -164,43 +170,47 @@ describe('AuthorCandidates', () => { }) describe('no adds', () => { - [ - ['adds cleanAuthor as candidate', [cleanAuthor]], - ].forEach(([name, expected]) => it(name, async () => { - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ;[['adds cleanAuthor as candidate', [cleanAuthor]]].forEach(([name, expected]) => + it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) describe('single add', () => { - [ + ;[ ['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']], - ['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]], - ].forEach(([name, author, expected]) => it(name, async () => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]] + ].forEach(([name, author, expected]) => + it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) }) describe('cleanAuthor is unrecognized and dirty', () => { describe('no adds', () => { - [ + ;[ ['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']], - ['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']], - ].forEach(([name, cleanAuthor, expected]) => it(name, async () => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']] + ].forEach(([name, cleanAuthor, expected]) => + it(name, async () => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) describe('single add', () => { - [ - ['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']], - ].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) + ;[['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']]].forEach(([name, cleanAuthor, author, expected]) => + it(name, async () => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + }) + ) }) }) }) @@ -211,16 +221,21 @@ describe('search', () => { const u = 'unrecognized' const r = ['book'] - const runSearchStub = sinon.stub(bookFinder, 'runSearch') - runSearchStub.resolves([]) - runSearchStub.withArgs(t, a).resolves(r) - runSearchStub.withArgs(t, u).resolves(r) - - const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest') - audnexusStub.resolves([{ name: a }]) + let runSearchStub + let audnexusStub beforeEach(() => { - bookFinder.runSearch.resetHistory() + runSearchStub = sinon.stub(bookFinder, 'runSearch') + runSearchStub.resolves([]) + runSearchStub.withArgs(t, a).resolves(r) + runSearchStub.withArgs(t, u).resolves(r) + + audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest') + audnexusStub.resolves([{ name: a }]) + }) + + afterEach(() => { + sinon.restore() }) describe('search title is empty', () => { @@ -238,50 +253,26 @@ describe('search', () => { }) describe('search title contains recognized title and search author is a recognized author', () => { - [ - [`${t} -`], - [`${t} - ${a}`], - [`${a} - ${t}`], - [`${t}- ${a}`], - [`${t} -${a}`], - [`${t} ${a}`], - [`${a} - ${t} (unabridged)`], - [`${a} - ${t} (subtitle) - mp3`], - [`${t} {narrator} - series-01 64kbps 10:00:00`], - [`${a} - ${t} (2006) narrated by narrator [unabridged]`], - [`${t} - ${a} 2022 mp3`], - [`01 ${t}`], - [`2022_${t}_HQ`], - ].forEach(([searchTitle]) => { + ;[[`${t} -`], [`${t} - ${a}`], [`${a} - ${t}`], [`${t}- ${a}`], [`${t} -${a}`], [`${t} ${a}`], [`${a} - ${t} (unabridged)`], [`${a} - ${t} (subtitle) - mp3`], [`${t} {narrator} - series-01 64kbps 10:00:00`], [`${a} - ${t} (2006) narrated by narrator [unabridged]`], [`${t} - ${a} 2022 mp3`], [`01 ${t}`], [`2022_${t}_HQ`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => { expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) - }); - - [ - [`s-01 - ${t} (narrator) 64kbps 10:00:00`], - [`${a} - series 01 - ${t}`], - ].forEach(([searchTitle]) => { + }) + ;[[`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => { expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 3) }) - }); - - [ - [`${t}-${a}`], - [`${t} junk`], - ].forEach(([searchTitle]) => { + }) + ;[[`${t}-${a}`], [`${t} junk`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result`, async () => { expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal([]) }) }) describe('maxFuzzySearches = 0', () => { - [ - [`${t} - ${a}`], - ].forEach(([searchTitle]) => { + ;[[`${t} - ${a}`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => { expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]) sinon.assert.callCount(bookFinder.runSearch, 1) @@ -290,10 +281,7 @@ describe('search', () => { }) describe('maxFuzzySearches = 1', () => { - [ - [`s-01 - ${t} (narrator) 64kbps 10:00:00`], - [`${a} - series 01 - ${t}`], - ].forEach(([searchTitle]) => { + ;[[`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => { expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]) sinon.assert.callCount(bookFinder.runSearch, 2) @@ -303,21 +291,13 @@ describe('search', () => { }) describe('search title contains recognized title and search author is empty', () => { - [ - [`${t} - ${a}`], - [`${a} - ${t}`], - ].forEach(([searchTitle]) => { + ;[[`${t} - ${a}`], [`${a} - ${t}`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => { expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) - }); - - [ - [`${t}`], - [`${t} - ${u}`], - [`${u} - ${t}`] - ].forEach(([searchTitle]) => { + }) + ;[[`${t}`], [`${t} - ${u}`], [`${u} - ${t}`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '') returns an empty result`, async () => { expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal([]) }) @@ -325,19 +305,13 @@ describe('search', () => { }) describe('search title contains recognized title and search author is an unrecognized author', () => { - [ - [`${t} - ${u}`], - [`${u} - ${t}`] - ].forEach(([searchTitle]) => { + ;[[`${t} - ${u}`], [`${u} - ${t}`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => { expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) - }); - - [ - [`${t}`] - ].forEach(([searchTitle]) => { + }) + ;[[`${t}`]].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => { expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 1) @@ -346,16 +320,19 @@ describe('search', () => { }) describe('search provider results have duration', () => { - const libraryItem = { media: { duration: 60 * 1000 } } + const libraryItem = { media: { duration: 60 * 1000 } } const provider = 'audible' const unsorted = [{ duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }] const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }] - runSearchStub.withArgs(t, a, provider).resolves(unsorted) + + beforeEach(() => { + runSearchStub.withArgs(t, a, provider).resolves(unsorted) + }) it('returns results sorted by library item duration diff', async () => { expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) }) - + it('returns unsorted results if library item is null', async () => { expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted) }) @@ -365,10 +342,10 @@ describe('search', () => { }) it('returns unsorted results if library item media is undefined', async () => { - expect(await bookFinder.search({ }, provider, t, a)).to.deep.equal(unsorted) + expect(await bookFinder.search({}, provider, t, a)).to.deep.equal(unsorted) }) - it ('should return a result last if it has no duration', async () => { + it('should return a result last if it has no duration', async () => { const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }] const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}] runSearchStub.withArgs(t, a, provider).resolves(unsorted) From 03ff5d8ae1467324be7a3aab517184c2b4ef74d9 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 11 Sep 2024 22:05:38 +0300 Subject: [PATCH 089/539] Disregard socketListener.level if level >= FATAL --- server/Logger.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Logger.js b/server/Logger.js index b10c45b7d0..5d1a7fa59d 100644 --- a/server/Logger.js +++ b/server/Logger.js @@ -86,7 +86,7 @@ class Logger { // Emit log to sockets that are listening to log events this.socketListeners.forEach((socketListener) => { - if (socketListener.level <= level) { + if (level >= LogLevel.FATAL || level >= socketListener.level) { socketListener.socket.emit('log', logObj) } }) From 61bd029303b66d1d349f93e5b2d0c3f49dbe95ad Mon Sep 17 00:00:00 2001 From: Aaron Graubert Date: Wed, 11 Sep 2024 22:42:21 -0600 Subject: [PATCH 090/539] Default deny explicit content to users --- server/models/User.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/User.js b/server/models/User.js index 2dd02b68c8..ef0c155474 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -107,7 +107,7 @@ class User extends Model { upload: type === 'root' || type === 'admin', accessAllLibraries: true, accessAllTags: true, - accessExplicitContent: true, + accessExplicitContent: false, selectedTagsNotAccessible: false, librariesAccessible: [], itemTagsSelected: [] From 6ae14213f5aa1a2b0ab73e72f0cbf3fdbfae2eda Mon Sep 17 00:00:00 2001 From: Aaron Graubert Date: Wed, 11 Sep 2024 23:08:00 -0600 Subject: [PATCH 091/539] Related ui changes for removing default explicit access --- client/components/modals/AccountModal.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index df1f7cbf26..247fd08dbf 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -351,7 +351,7 @@ export default { update: type === 'admin', delete: type === 'admin', upload: type === 'admin', - accessExplicitContent: true, + accessExplicitContent: type === 'admin', accessAllLibraries: true, accessAllTags: true, selectedTagsNotAccessible: false @@ -386,7 +386,7 @@ export default { upload: false, accessAllLibraries: true, accessAllTags: true, - accessExplicitContent: true, + accessExplicitContent: false, selectedTagsNotAccessible: false }, librariesAccessible: [], From 2df3277dcde10ed19eb6432138be45b4bf8b1d59 Mon Sep 17 00:00:00 2001 From: Aaron Graubert Date: Wed, 11 Sep 2024 23:09:04 -0600 Subject: [PATCH 092/539] Server side change to enable default explicit acces for admins --- server/models/User.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/User.js b/server/models/User.js index ef0c155474..4333db88e6 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -107,7 +107,7 @@ class User extends Model { upload: type === 'root' || type === 'admin', accessAllLibraries: true, accessAllTags: true, - accessExplicitContent: false, + accessExplicitContent: type === 'root' || type === 'admin', selectedTagsNotAccessible: false, librariesAccessible: [], itemTagsSelected: [] From 1099dbe642490195bc65b4439a670e515e45ae08 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 12 Sep 2024 18:56:52 +0300 Subject: [PATCH 093/539] Handle library scan failure gracefully --- server/scanner/LibraryScanner.js | 59 +++++++++++++++++++------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 75d18df099..3ac97cc696 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -79,38 +79,49 @@ class LibraryScanner { Logger.info(`[LibraryScanner] Starting${forceRescan ? ' (forced)' : ''} library scan ${libraryScan.id} for ${libraryScan.libraryName}`) - const canceled = await this.scanLibrary(libraryScan, forceRescan) + try { + const canceled = await this.scanLibrary(libraryScan, forceRescan) - if (canceled) { - Logger.info(`[LibraryScanner] Library scan canceled for "${libraryScan.libraryName}"`) - delete this.cancelLibraryScan[libraryScan.libraryId] - } + if (canceled) { + Logger.info(`[LibraryScanner] Library scan canceled for "${libraryScan.libraryName}"`) + delete this.cancelLibraryScan[libraryScan.libraryId] + } - libraryScan.setComplete() + libraryScan.setComplete() - Logger.info(`[LibraryScanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`) - this.librariesScanning = this.librariesScanning.filter((ls) => ls.id !== library.id) + Logger.info(`[LibraryScanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`) - if (canceled && !libraryScan.totalResults) { - task.setFinished('Scan canceled') - TaskManager.taskFinished(task) + if (canceled && !libraryScan.totalResults) { + task.setFinished('Scan canceled') + TaskManager.taskFinished(task) - const emitData = libraryScan.getScanEmitData - emitData.results = null - return - } + const emitData = libraryScan.getScanEmitData + emitData.results = null + return + } - library.lastScan = Date.now() - library.lastScanVersion = packageJson.version - if (library.isBook) { - const newExtraData = library.extraData || {} - newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence - library.extraData = newExtraData - library.changed('extraData', true) + library.lastScan = Date.now() + library.lastScanVersion = packageJson.version + if (library.isBook) { + const newExtraData = library.extraData || {} + newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence + library.extraData = newExtraData + library.changed('extraData', true) + } + await library.save() + + task.setFinished(libraryScan.scanResultsString) + } catch (err) { + libraryScan.setComplete(err) + Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp}.`, err) + + if (this.cancelLibraryScan[libraryScan.libraryId]) delete this.cancelLibraryScan[libraryScan.libraryId] + + task.setFailed(`Scan failed: ${err.message}`) } - await library.save() - task.setFinished(libraryScan.scanResultsString) + this.librariesScanning = this.librariesScanning.filter((ls) => ls.id !== library.id) + TaskManager.taskFinished(task) if (libraryScan.totalResults) { From 0c24a1e626f5b0ad83ebeb91d4b2c597185d60b1 Mon Sep 17 00:00:00 2001 From: Lauri Vuorela Date: Thu, 12 Sep 2024 19:46:08 +0200 Subject: [PATCH 094/539] add duration to session when creating --- server/managers/PlaybackSessionManager.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index ccdf6c7a52..d58bc55b93 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -164,6 +164,7 @@ class PlaybackSessionManager { // New session from local session = new PlaybackSession(sessionJson) session.deviceInfo = deviceInfo + session.duration = libraryItem.media.duration Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) await Database.createPlaybackSession(session) } else { From d430d9f3ed6d7b3f662bea94d3b9738d3bcd5ddf Mon Sep 17 00:00:00 2001 From: Lauri Vuorela Date: Thu, 12 Sep 2024 20:05:08 +0200 Subject: [PATCH 095/539] add new setDuration and use that --- server/managers/PlaybackSessionManager.js | 2 +- server/objects/PlaybackSession.js | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index d58bc55b93..14a60d18e3 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -164,7 +164,7 @@ class PlaybackSessionManager { // New session from local session = new PlaybackSession(sessionJson) session.deviceInfo = deviceInfo - session.duration = libraryItem.media.duration + session.setDuration(libraryItem, sessionJson.episodeId) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) await Database.createPlaybackSession(session) } else { diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index cd74089ac4..a294b72cc9 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -219,11 +219,7 @@ class PlaybackSession { this.displayAuthor = libraryItem.media.getPlaybackAuthor() this.coverPath = libraryItem.media.coverPath - if (episodeId) { - this.duration = libraryItem.media.getEpisodeDuration(episodeId) - } else { - this.duration = libraryItem.media.duration - } + this.setDuration(libraryItem, episodeId) this.mediaPlayer = mediaPlayer this.deviceInfo = deviceInfo || new DeviceInfo() @@ -239,6 +235,14 @@ class PlaybackSession { this.updatedAt = Date.now() } + setDuration(libraryItem, episodeId) { + if (episodeId) { + this.duration = libraryItem.media.getEpisodeDuration(episodeId) + } else { + this.duration = libraryItem.media.duration + } + } + addListeningTime(timeListened) { if (!timeListened || isNaN(timeListened)) return From 01fbea02f19bb19b5d2e8629fdbe4662d278e872 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 12 Sep 2024 16:36:39 -0500 Subject: [PATCH 096/539] Clean out old unused functions, Device updates for replacing DeviceInfo --- server/Database.js | 20 ++---- server/managers/PlaybackSessionManager.js | 6 +- server/models/CustomMetadataProvider.js | 79 +++++++++++----------- server/models/Device.js | 81 +++++++++++++++-------- server/objects/PlaybackSession.js | 10 +-- server/objects/mediaTypes/Podcast.js | 9 --- server/objects/metadata/BookMetadata.js | 65 ++++++------------ 7 files changed, 127 insertions(+), 143 deletions(-) diff --git a/server/Database.js b/server/Database.js index 289bef0927..9eedfe1c5f 100644 --- a/server/Database.js +++ b/server/Database.js @@ -144,6 +144,11 @@ class Database { return this.models.mediaItemShare } + /** @type {typeof import('./models/Device')} */ + get deviceModel() { + return this.models.device + } + /** * Check if db file exists * @returns {boolean} @@ -489,21 +494,6 @@ class Database { return this.models.playbackSession.removeById(sessionId) } - getDeviceByDeviceId(deviceId) { - if (!this.sequelize) return false - return this.models.device.getOldDeviceByDeviceId(deviceId) - } - - updateDevice(oldDevice) { - if (!this.sequelize) return false - return this.models.device.updateFromOld(oldDevice) - } - - createDevice(oldDevice) { - if (!this.sequelize) return false - return this.models.device.createFromOld(oldDevice) - } - replaceTagInFilterData(oldTag, newTag) { for (const libraryId in this.libraryFilterData) { const indexOf = this.libraryFilterData[libraryId].tags.findIndex((n) => n === oldTag) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 14a60d18e3..4318841e1e 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -51,16 +51,16 @@ class PlaybackSessionManager { deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user?.id) if (clientDeviceInfo?.deviceId) { - const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId) + const existingDevice = await Database.deviceModel.getOldDeviceByDeviceId(clientDeviceInfo.deviceId) if (existingDevice) { if (existingDevice.update(deviceInfo)) { - await Database.updateDevice(existingDevice) + await Database.deviceModel.updateFromOld(existingDevice) } return existingDevice } } - await Database.createDevice(deviceInfo) + await Database.deviceModel.createFromOld(deviceInfo) return deviceInfo } diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js index 8218e41961..ca2e20a72d 100644 --- a/server/models/CustomMetadataProvider.js +++ b/server/models/CustomMetadataProvider.js @@ -30,28 +30,11 @@ class CustomMetadataProvider extends Model { this.updatedAt } - getSlug() { - return `custom-${this.id}` - } - - /** - * Safe for clients - * @returns {ClientCustomMetadataProvider} - */ - toClientJson() { - return { - id: this.id, - name: this.name, - mediaType: this.mediaType, - slug: this.getSlug() - } - } - /** * Get providers for client by media type * Currently only available for "book" media type - * - * @param {string} mediaType + * + * @param {string} mediaType * @returns {Promise} */ static async getForClientByMediaType(mediaType) { @@ -61,13 +44,13 @@ class CustomMetadataProvider extends Model { mediaType } }) - return customMetadataProviders.map(cmp => cmp.toClientJson()) + return customMetadataProviders.map((cmp) => cmp.toClientJson()) } /** * Check if provider exists by slug - * - * @param {string} providerSlug + * + * @param {string} providerSlug * @returns {Promise} */ static async checkExistsBySlug(providerSlug) { @@ -79,25 +62,45 @@ class CustomMetadataProvider extends Model { /** * Initialize model - * @param {import('../Database').sequelize} sequelize + * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + mediaType: DataTypes.STRING, + url: DataTypes.STRING, + authHeaderValue: DataTypes.STRING, + extraData: DataTypes.JSON }, - name: DataTypes.STRING, - mediaType: DataTypes.STRING, - url: DataTypes.STRING, - authHeaderValue: DataTypes.STRING, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'customMetadataProvider' - }) + { + sequelize, + modelName: 'customMetadataProvider' + } + ) + } + + getSlug() { + return `custom-${this.id}` + } + + /** + * Safe for clients + * @returns {ClientCustomMetadataProvider} + */ + toClientJson() { + return { + id: this.id, + name: this.name, + mediaType: this.mediaType, + slug: this.getSlug() + } } } -module.exports = CustomMetadataProvider \ No newline at end of file +module.exports = CustomMetadataProvider diff --git a/server/models/Device.js b/server/models/Device.js index 896967e4ef..8866d35789 100644 --- a/server/models/Device.js +++ b/server/models/Device.js @@ -29,33 +29,6 @@ class Device extends Model { this.updatedAt } - getOldDevice() { - let browserVersion = null - let sdkVersion = null - if (this.clientName === 'Abs Android') { - sdkVersion = this.deviceVersion || null - } else { - browserVersion = this.deviceVersion || null - } - - return new oldDevice({ - id: this.id, - deviceId: this.deviceId, - userId: this.userId, - ipAddress: this.ipAddress, - browserName: this.extraData.browserName || null, - browserVersion, - osName: this.extraData.osName || null, - osVersion: this.extraData.osVersion || null, - clientVersion: this.clientVersion || null, - manufacturer: this.extraData.manufacturer || null, - model: this.extraData.model || null, - sdkVersion, - deviceName: this.deviceName, - clientName: this.clientName - }) - } - static async getOldDeviceByDeviceId(deviceId) { const device = await this.findOne({ where: { @@ -145,6 +118,60 @@ class Device extends Model { }) Device.belongsTo(user) } + + toOldJSON() { + let browserVersion = null + let sdkVersion = null + if (this.clientName === 'Abs Android') { + sdkVersion = this.deviceVersion || null + } else { + browserVersion = this.deviceVersion || null + } + + return { + id: this.id, + deviceId: this.deviceId, + userId: this.userId, + ipAddress: this.ipAddress, + browserName: this.extraData.browserName || null, + browserVersion, + osName: this.extraData.osName || null, + osVersion: this.extraData.osVersion || null, + clientVersion: this.clientVersion || null, + manufacturer: this.extraData.manufacturer || null, + model: this.extraData.model || null, + sdkVersion, + deviceName: this.deviceName, + clientName: this.clientName + } + } + + getOldDevice() { + let browserVersion = null + let sdkVersion = null + if (this.clientName === 'Abs Android') { + sdkVersion = this.deviceVersion || null + } else { + browserVersion = this.deviceVersion || null + } + + return new oldDevice({ + id: this.id, + deviceId: this.deviceId, + userId: this.userId, + ipAddress: this.ipAddress, + browserName: this.extraData.browserName || null, + browserVersion, + osName: this.extraData.osName || null, + osVersion: this.extraData.osVersion || null, + clientVersion: this.clientVersion || null, + manufacturer: this.extraData.manufacturer || null, + model: this.extraData.model || null, + sdkVersion, + deviceName: this.deviceName, + clientName: this.clientName + }) + } } module.exports = Device diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index a294b72cc9..6950a54421 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -82,8 +82,8 @@ class PlaybackSession { /** * Session data to send to clients - * @param {[oldLibraryItem]} libraryItem optional - * @returns {object} + * @param {Object} [libraryItem] - old library item + * @returns */ toJSONForClient(libraryItem) { return { @@ -255,11 +255,5 @@ class PlaybackSession { this.timeListening += Number.parseFloat(timeListened) this.updatedAt = Date.now() } - - // New date since start of listening session - checkDateRollover() { - if (!this.date) return false - return date.format(new Date(), 'YYYY-MM-DD') !== this.date - } } module.exports = PlaybackSession diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index bca741a26e..c7d91d0da9 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -233,15 +233,6 @@ class Podcast { this.episodes.push(podcastEpisode) } - addNewEpisodeFromAudioFile(audioFile, index) { - const pe = new PodcastEpisode() - pe.libraryItemId = this.libraryItemId - pe.podcastId = this.id - audioFile.index = 1 // Only 1 audio file per episode - pe.setDataFromAudioFile(audioFile, index) - this.episodes.push(pe) - } - removeEpisode(episodeId) { const episode = this.episodes.find((ep) => ep.id === episodeId) if (episode) { diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 490b994933..6d3dae4326 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -6,7 +6,7 @@ class BookMetadata { this.title = null this.subtitle = null this.authors = [] - this.narrators = [] // Array of strings + this.narrators = [] // Array of strings this.series = [] this.genres = [] // Array of strings this.publishedYear = null @@ -27,9 +27,9 @@ class BookMetadata { construct(metadata) { this.title = metadata.title this.subtitle = metadata.subtitle - this.authors = (metadata.authors?.map) ? metadata.authors.map(a => ({ ...a })) : [] - this.narrators = metadata.narrators ? [...metadata.narrators].filter(n => n) : [] - this.series = (metadata.series?.map) ? metadata.series.map(s => ({ ...s })) : [] + this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : [] + this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : [] + this.series = metadata.series?.map ? metadata.series.map((s) => ({ ...s })) : [] this.genres = metadata.genres ? [...metadata.genres] : [] this.publishedYear = metadata.publishedYear || null this.publishedDate = metadata.publishedDate || null @@ -46,9 +46,9 @@ class BookMetadata { return { title: this.title, subtitle: this.subtitle, - authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id + authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id narrators: [...this.narrators], - series: this.series.map(s => ({ ...s })), // Series JSONMinimal with name, id and sequence + series: this.series.map((s) => ({ ...s })), // Series JSONMinimal with name, id and sequence genres: [...this.genres], publishedYear: this.publishedYear, publishedDate: this.publishedDate, @@ -89,9 +89,9 @@ class BookMetadata { title: this.title, titleIgnorePrefix: this.titlePrefixAtEnd, subtitle: this.subtitle, - authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id + authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id narrators: [...this.narrators], - series: this.series.map(s => ({ ...s })), + series: this.series.map((s) => ({ ...s })), genres: [...this.genres], publishedYear: this.publishedYear, publishedDate: this.publishedDate, @@ -111,8 +111,8 @@ class BookMetadata { toJSONForMetadataFile() { const json = this.toJSON() - json.authors = json.authors.map(au => au.name) - json.series = json.series.map(se => { + json.authors = json.authors.map((au) => au.name) + json.series = json.series.map((se) => { if (!se.sequence) return se.name return `${se.name} #${se.sequence}` }) @@ -131,36 +131,31 @@ class BookMetadata { } get authorName() { if (!this.authors.length) return '' - return this.authors.map(au => au.name).join(', ') + return this.authors.map((au) => au.name).join(', ') } - get authorNameLF() { // Last, First + get authorNameLF() { + // Last, First if (!this.authors.length) return '' - return this.authors.map(au => parseNameString.nameToLastFirst(au.name)).join(', ') + return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ') } get seriesName() { if (!this.series.length) return '' - return this.series.map(se => { - if (!se.sequence) return se.name - return `${se.name} #${se.sequence}` - }).join(', ') - } - get firstSeriesName() { - if (!this.series.length) return '' - return this.series[0].name - } - get firstSeriesSequence() { - if (!this.series.length) return '' - return this.series[0].sequence + return this.series + .map((se) => { + if (!se.sequence) return se.name + return `${se.name} #${se.sequence}` + }) + .join(', ') } get narratorName() { return this.narrators.join(', ') } getSeries(seriesId) { - return this.series.find(se => se.id == seriesId) + return this.series.find((se) => se.id == seriesId) } getSeriesSequence(seriesId) { - const series = this.series.find(se => se.id == seriesId) + const series = this.series.find((se) => se.id == seriesId) if (!series) return null return series.sequence || '' } @@ -180,21 +175,5 @@ class BookMetadata { } return hasUpdates } - - // Updates author name - updateAuthor(updatedAuthor) { - const author = this.authors.find(au => au.id === updatedAuthor.id) - if (!author || author.name == updatedAuthor.name) return false - author.name = updatedAuthor.name - return true - } - - replaceAuthor(oldAuthor, newAuthor) { - this.authors = this.authors.filter(au => au.id !== oldAuthor.id) // Remove old author - this.authors.push({ - id: newAuthor.id, - name: newAuthor.name - }) - } } module.exports = BookMetadata From f8034e1b781a3732b7338b122a684914e630241b Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 13 Sep 2024 09:23:48 +0300 Subject: [PATCH 097/539] scanLibrary fail and cancel handling round 2 --- server/scanner/LibraryScan.js | 7 ++-- server/scanner/LibraryScanner.js | 68 +++++++++++++++----------------- 2 files changed, 36 insertions(+), 39 deletions(-) diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index 5ae5c06ada..8994aa231c 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -75,13 +75,14 @@ class LibraryScan { return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt' } get scanResultsString() { - if (this.error) return this.error const strs = [] if (this.resultsAdded) strs.push(`${this.resultsAdded} added`) if (this.resultsUpdated) strs.push(`${this.resultsUpdated} updated`) if (this.resultsMissing) strs.push(`${this.resultsMissing} missing`) - if (!strs.length) return `Everything was up to date (${elapsedPretty(this.elapsed / 1000)})` - return strs.join(', ') + ` (${elapsedPretty(this.elapsed / 1000)})` + const changesDetected = strs.length > 0 ? strs.join(', ') : 'No changes detected' + const timeElapsed = `(${elapsedPretty(this.elapsed / 1000)})` + const error = this.error ? `${this.error}. ` : '' + return `${error}${changesDetected} ${timeElapsed}` } toJSON() { diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 3ac97cc696..760224158f 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -81,52 +81,37 @@ class LibraryScanner { try { const canceled = await this.scanLibrary(libraryScan, forceRescan) - - if (canceled) { - Logger.info(`[LibraryScanner] Library scan canceled for "${libraryScan.libraryName}"`) - delete this.cancelLibraryScan[libraryScan.libraryId] - } - libraryScan.setComplete() - Logger.info(`[LibraryScanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`) - - if (canceled && !libraryScan.totalResults) { - task.setFinished('Scan canceled') - TaskManager.taskFinished(task) - - const emitData = libraryScan.getScanEmitData - emitData.results = null - return - } + Logger.info(`[LibraryScanner] Library scan "${libraryScan.id}" ${canceled ? 'canceled after' : 'completed in'} ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`) - library.lastScan = Date.now() - library.lastScanVersion = packageJson.version - if (library.isBook) { - const newExtraData = library.extraData || {} - newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence - library.extraData = newExtraData - library.changed('extraData', true) + if (!canceled) { + library.lastScan = Date.now() + library.lastScanVersion = packageJson.version + if (library.isBook) { + const newExtraData = library.extraData || {} + newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence + library.extraData = newExtraData + library.changed('extraData', true) + } + await library.save() } - await library.save() - task.setFinished(libraryScan.scanResultsString) + task.setFinished(`${canceled ? 'Canceled' : 'Completed'}. ${libraryScan.scanResultsString}`) } catch (err) { libraryScan.setComplete(err) - Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp}.`, err) - if (this.cancelLibraryScan[libraryScan.libraryId]) delete this.cancelLibraryScan[libraryScan.libraryId] + Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}.`, err) - task.setFailed(`Scan failed: ${err.message}`) + task.setFailed(`Failed. ${libraryScan.scanResultsString}`) } + if (this.cancelLibraryScan[libraryScan.libraryId]) delete this.cancelLibraryScan[libraryScan.libraryId] this.librariesScanning = this.librariesScanning.filter((ls) => ls.id !== library.id) TaskManager.taskFinished(task) - if (libraryScan.totalResults) { - libraryScan.saveLog() - } + libraryScan.saveLog() } /** @@ -151,7 +136,7 @@ class LibraryScanner { libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder) } - if (this.cancelLibraryScan[libraryScan.libraryId]) return true + if (this.shouldCancelScan(libraryScan)) return true const existingLibraryItems = await Database.libraryItemModel.findAll({ where: { @@ -159,7 +144,7 @@ class LibraryScanner { } }) - if (this.cancelLibraryScan[libraryScan.libraryId]) return true + if (this.shouldCancelScan(libraryScan)) return true const libraryItemIdsMissing = [] let oldLibraryItemsUpdated = [] @@ -227,7 +212,7 @@ class LibraryScanner { oldLibraryItemsUpdated = [] } - if (this.cancelLibraryScan[libraryScan.libraryId]) return true + if (this.shouldCancelScan(libraryScan)) return true } // Emit item updates to client if (oldLibraryItemsUpdated.length) { @@ -258,7 +243,7 @@ class LibraryScanner { ) } - if (this.cancelLibraryScan[libraryScan.libraryId]) return true + if (this.shouldCancelScan(libraryScan)) return true // Add new library items if (libraryItemDataFound.length) { @@ -282,7 +267,7 @@ class LibraryScanner { newOldLibraryItems = [] } - if (this.cancelLibraryScan[libraryScan.libraryId]) return true + if (this.shouldCancelScan(libraryScan)) return true } // Emit new items to client if (newOldLibraryItems.length) { @@ -293,6 +278,17 @@ class LibraryScanner { ) } } + + libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`) + return false + } + + shouldCancelScan(libraryScan) { + if (this.cancelLibraryScan[libraryScan.libraryId]) { + libraryScan.addLog(LogLevel.INFO, `Scan canceled. ${libraryScan.resultStats}`) + return true + } + return false } /** From def34a860b4fb1d32f199161139e052ff3b78add Mon Sep 17 00:00:00 2001 From: Oleg Ivasenko Date: Fri, 13 Sep 2024 16:23:25 +0000 Subject: [PATCH 098/539] when checking if series/author is alread in DB, use case insensitive match only for ASCII names --- server/models/Author.js | 26 +++++++++++++++++++------- server/models/Series.js | 26 +++++++++++++++++++------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/server/models/Author.js b/server/models/Author.js index f3bbba5740..7115a85e5d 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -53,14 +53,26 @@ class Author extends Model { * @returns {Promise} */ static async getByNameAndLibrary(authorName, libraryId) { - return this.findOne({ - where: [ - where(fn('lower', col('name')), authorName.toLowerCase()), - { - libraryId + const containsOnlyASCII = /^[\u0000-\u007f]*$/.test(authorName) + + // SQLite does not support lower with non-Unicode chars + if (!containsOnlyASCII) { + return this.findOne({ + where: { + name: authorName, + libraryId: libraryId } - ] - }) + }) + } else { + return this.findOne({ + where: [ + where(fn('lower', col('name')), authorName.toLowerCase()), + { + libraryId + } + ] + }) + } } /** diff --git a/server/models/Series.js b/server/models/Series.js index c57a1a116f..9eab76b904 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -39,14 +39,26 @@ class Series extends Model { * @returns {Promise} */ static async getByNameAndLibrary(seriesName, libraryId) { - return this.findOne({ - where: [ - where(fn('lower', col('name')), seriesName.toLowerCase()), - { - libraryId + const containsOnlyASCII = /^[\u0000-\u007f]*$/.test(authorName) + + // SQLite does not support lower with non-Unicode chars + if (!containsOnlyASCII) { + return this.findOne({ + where: { + name: seriesName, + libraryId: libraryId } - ] - }) + }) + } else { + return this.findOne({ + where: [ + where(fn('lower', col('name')), seriesName.toLowerCase()), + { + libraryId + } + ] + }) + } } /** From 0af29a378a98417856e96dd7a6f296ae69095f4a Mon Sep 17 00:00:00 2001 From: Oleg Ivasenko Date: Fri, 13 Sep 2024 17:09:32 +0000 Subject: [PATCH 099/539] use asciiOnlyToLowerCase to match lower function behaviour of SQLite --- server/models/Author.js | 27 ++++++++------------------- server/models/Series.js | 27 ++++++++------------------- 2 files changed, 16 insertions(+), 38 deletions(-) diff --git a/server/models/Author.js b/server/models/Author.js index 7115a85e5d..40e7f75a47 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -1,5 +1,6 @@ const { DataTypes, Model, where, fn, col } = require('sequelize') const parseNameString = require('../utils/parsers/parseNameString') +const { asciiOnlyToLowerCase } = require('../utils/index') class Author extends Model { constructor(values, options) { @@ -53,26 +54,14 @@ class Author extends Model { * @returns {Promise} */ static async getByNameAndLibrary(authorName, libraryId) { - const containsOnlyASCII = /^[\u0000-\u007f]*$/.test(authorName) - - // SQLite does not support lower with non-Unicode chars - if (!containsOnlyASCII) { - return this.findOne({ - where: { - name: authorName, - libraryId: libraryId + return this.findOne({ + where: [ + where(fn('lower', col('name')), asciiOnlyToLowerCase(authorName)), + { + libraryId } - }) - } else { - return this.findOne({ - where: [ - where(fn('lower', col('name')), authorName.toLowerCase()), - { - libraryId - } - ] - }) - } + ] + }) } /** diff --git a/server/models/Series.js b/server/models/Series.js index 9eab76b904..dc8d110fd6 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -1,6 +1,7 @@ const { DataTypes, Model, where, fn, col } = require('sequelize') const { getTitlePrefixAtEnd } = require('../utils/index') +const { asciiOnlyToLowerCase } = require('../utils/index') class Series extends Model { constructor(values, options) { @@ -39,26 +40,14 @@ class Series extends Model { * @returns {Promise} */ static async getByNameAndLibrary(seriesName, libraryId) { - const containsOnlyASCII = /^[\u0000-\u007f]*$/.test(authorName) - - // SQLite does not support lower with non-Unicode chars - if (!containsOnlyASCII) { - return this.findOne({ - where: { - name: seriesName, - libraryId: libraryId + return this.findOne({ + where: [ + where(fn('lower', col('name')), asciiOnlyToLowerCase(seriesName)), + { + libraryId } - }) - } else { - return this.findOne({ - where: [ - where(fn('lower', col('name')), seriesName.toLowerCase()), - { - libraryId - } - ] - }) - } + ] + }) } /** From 2711b989e153669aaf869521ebbc0e9f006834ef Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 13 Sep 2024 16:55:48 -0700 Subject: [PATCH 100/539] Add: series migration to be unique --- .../v2.13.5-series-column-unique.js | 126 ++++++++++ server/models/Series.js | 6 + .../v2.13.5-series-column-unique.test.js | 226 ++++++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 server/migrations/v2.13.5-series-column-unique.js create mode 100644 test/server/migrations/v2.13.5-series-column-unique.test.js diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js new file mode 100644 index 0000000000..e7201bae1c --- /dev/null +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -0,0 +1,126 @@ +const Logger = require('../Logger') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script cleans any duplicate series in the `Series` table and + * adds a unique index on the `name` and `libraryId` columns. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('UPGRADE BEGIN: 2.13.5-series-column-unique ') + + // Use the queryInterface to get the series table and find duplicates in the `name` column + const [duplicates] = await queryInterface.sequelize.query(` + SELECT name, libraryId, MAX(updatedAt) AS latestUpdatedAt, COUNT(name) AS count + FROM Series + GROUP BY name, libraryId + HAVING COUNT(name) > 1 + `) + + // Print out how many duplicates were found + logger.info(`[2.13.5 migration] Found ${duplicates.length} duplicate series`) + + // Iterate over each duplicate series + for (const duplicate of duplicates) { + // Report the series name that is being deleted + logger.info(`[2.13.5 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) + + // Get all the most recent series which matches the `name` and `libraryId` + const [mostRecentSeries] = await queryInterface.sequelize.query( + ` + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ORDER BY updatedAt DESC + LIMIT 1 + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId + }, + type: queryInterface.sequelize.QueryTypes.SELECT + } + ) + + if (mostRecentSeries) { + // Update all BookSeries records for this series to point to the most recent series + const [seriesUpdated] = await queryInterface.sequelize.query( + ` + UPDATE BookSeries + SET seriesId = :mostRecentSeriesId + WHERE seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + AND id != :mostRecentSeriesId + ) + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId, + mostRecentSeriesId: mostRecentSeries.id + } + } + ) + + // Delete the older series + const seriesDeleted = await queryInterface.sequelize.query( + ` + DELETE FROM Series + WHERE name = :name AND libraryId = :libraryId + AND id != :mostRecentSeriesId + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId, + mostRecentSeriesId: mostRecentSeries.id + } + } + ) + } + } + + logger.info(`[2.13.5 migration] Deduplication complete`) + + // Create a unique index based on the name and library ID for the `Series` table + await queryInterface.addIndex('Series', ['name', 'libraryId'], { + unique: true, + name: 'unique_series_name_per_library' + }) + logger.info('Added unique index on Series.name and Series.libraryId') + + logger.info('UPGRADE END: 2.13.5-series-column-unique ') +} + +/** + * This removes the unique index on the `Series` table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('DOWNGRADE BEGIN: 2.13.5-series-column-unique ') + + // Remove the unique index + await queryInterface.removeIndex('Series', 'unique_series_name_per_library') + logger.info('Removed unique index on Series.name and Series.libraryId') + + logger.info('DOWNGRADE END: 2.13.5-series-column-unique ') +} + +module.exports = { up, down } diff --git a/server/models/Series.js b/server/models/Series.js index c57a1a116f..731908e9ca 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -83,6 +83,12 @@ class Series extends Model { // collate: 'NOCASE' // }] // }, + { + // unique constraint on name and libraryId + fields: ['name', 'libraryId'], + unique: true, + name: 'unique_series_name_per_library' + }, { fields: ['libraryId'] } diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js new file mode 100644 index 0000000000..6d55b62932 --- /dev/null +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -0,0 +1,226 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.13.5-series-column-unique') +const { Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') +const { query } = require('express') +const { logger } = require('sequelize/lib/utils/logger') +const e = require('express') + +describe('migration_example', () => { + let sequelize + let queryInterface + let loggerInfoStub + let series1Id + let series2Id + let series3Id + let series1Id_dup + let series3Id_dup + let book1Id + let book2Id + let book3Id + let book4Id + let book5Id + let library1Id + let library2Id + let bookSeries1Id + let bookSeries2Id + let bookSeries3Id + let bookSeries1Id_dup + let bookSeries3Id_dup + + beforeEach(() => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + beforeEach(async () => { + await queryInterface.createTable('Series', { + id: { type: Sequelize.UUID, primaryKey: true }, + name: { type: Sequelize.STRING, allowNull: false }, + libraryId: { type: Sequelize.UUID, allowNull: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false } + }) + await queryInterface.createTable('BookSeries', { + id: { type: Sequelize.UUID, primaryKey: true }, + bookId: { type: Sequelize.UUID, allowNull: false }, + seriesId: { type: Sequelize.UUID, allowNull: false } + }) + // Set UUIDs for the tests + series1Id = 'fc086255-3fd2-4a95-8a28-840d9206501b' + series2Id = '70f46ac2-ee48-4b3c-9822-933cc15c29bd' + series3Id = '01cac008-142b-4e15-b0ff-cf7cc2c5b64e' + series1Id_dup = 'ad0b3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + series3Id_dup = '4b3b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + series1Id_dup2 = '0123456a-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + book1Id = '4a38b6e5-0ae4-4de4-b119-4e33891bd63f' + book2Id = '8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f' + book3Id = 'ec9bbaaf-1e55-457f-b59c-bd2bd955a404' + book4Id = '876f3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + book5Id = '4e5b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + book6Id = 'abcda123-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + library1Id = '3a5a1c7c-a914-472e-88b0-b871ceae63e7' + library2Id = 'fd6c324a-4f3a-4bb0-99d6-7a330e765e7e' + bookSeries1Id = 'eca24687-2241-4ffa-a9b3-02a0ba03c763' + bookSeries2Id = '56f56105-813b-4395-9689-fd04198e7d5d' + bookSeries3Id = '404a1761-c710-4d86-9d78-68d9a9c0fb6b' + bookSeries1Id_dup = '8bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + bookSeries3Id_dup = '89656a3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + bookSeries1Id_dup2 = '9bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + }) + afterEach(async () => { + await queryInterface.dropTable('Series') + await queryInterface.dropTable('BookSeries') + }) + it('upgrade with no duplicate series', async () => { + // Add some entries to the Series table using the UUID for the ids + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Add some entries to the BookSeries table + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }, + { id: bookSeries3Id, bookId: book3Id, seriesId: series3Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(5) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // Validate rows in tables + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(3) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id }) + expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(3) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) + expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }) + }) + it('upgrade with duplicate series', async () => { + // Add some entries to the Series table using the UUID for the ids + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Add some entries to the BookSeries table + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }, + { id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }, + { id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id_dup }, + { id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id_dup }, + { id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id_dup2 } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(7) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 2 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 3" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // Validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(3) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id }) + expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(6) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) + expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }) + expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id }) + expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id }) + }) + it('update with same series name in different libraries', async () => { + // Add some entries to the Series table using the UUID for the ids + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 1', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Add some entries to the BookSeries table + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(5) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // Validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(2) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + expect(series).to.deep.include({ id: series2Id, name: 'Series 1', libraryId: library2Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(2) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) + }) + }) + + describe('down', () => { + beforeEach(async () => { + await queryInterface.createTable('Series', { + id: { type: Sequelize.UUID, primaryKey: true }, + name: { type: Sequelize.STRING, allowNull: false }, + libraryId: { type: Sequelize.UUID, allowNull: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false } + }) + await queryInterface.createTable('BookSeries', { + id: { type: Sequelize.UUID, primaryKey: true }, + bookId: { type: Sequelize.UUID, allowNull: false }, + seriesId: { type: Sequelize.UUID, allowNull: false } + }) + }) + it('should not have unique constraint on series name and libraryId', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(8) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('DOWNGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Removed unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('DOWNGRADE END: 2.13.5-series-column-unique '))).to.be.true + // Ensure index does not exist + const indexes = await queryInterface.showIndex('Series') + expect(indexes).to.not.deep.include({ tableName: 'Series', unique: true, fields: ['name', 'libraryId'], name: 'unique_series_name_per_library' }) + }) + }) +}) From c163f84aec65da8de7d31390e100d83dff86312a Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 13 Sep 2024 17:01:48 -0700 Subject: [PATCH 101/539] Update migration changelog for series name unique --- server/migrations/changelog.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 2e3c295af1..bac3ec25e5 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,6 +2,6 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | --------------------- | ----------- | -| | | | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------- | +| v2.13.5 | v2.13.5-series-column-unique | Series must have unique names in the same library | From 55164803b07222467d1bfaf88ab08f8ebe32391d Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 14 Sep 2024 08:01:32 +0300 Subject: [PATCH 102/539] Fix migrationMeta database version initial value, and move isDatabaseNew logic inside MigrationManager --- server/Database.js | 4 +- server/managers/MigrationManager.js | 24 +++++- test/server/managers/MigrationManager.test.js | 73 ++++++++++++++++--- 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/server/Database.js b/server/Database.js index 289bef0927..a24be8093a 100644 --- a/server/Database.js +++ b/server/Database.js @@ -171,9 +171,9 @@ class Database { } try { - const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath) + const migrationManager = new MigrationManager(this.sequelize, this.isNew, global.ConfigPath) await migrationManager.init(packageJson.version) - if (!this.isNew) await migrationManager.runMigrations() + await migrationManager.runMigrations() } catch (error) { Logger.error(`[Database] Failed to run migrations`, error) throw new Error('Database migration failed') diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index 53db461bfb..706e359cf1 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -11,11 +11,13 @@ class MigrationManager { /** * @param {import('../Database').sequelize} sequelize + * @param {boolean} isDatabaseNew * @param {string} [configPath] */ - constructor(sequelize, configPath = global.configPath) { + constructor(sequelize, isDatabaseNew, configPath = global.configPath) { if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.') this.sequelize = sequelize + this.isDatabaseNew = isDatabaseNew if (!configPath) throw new Error('Config path is required for MigrationManager.') this.configPath = configPath this.migrationsSourceDir = path.join(__dirname, '..', 'migrations') @@ -42,6 +44,7 @@ class MigrationManager { await this.fetchVersionsFromDatabase() if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.') + Logger.debug(`[MigrationManager] Database version: ${this.databaseVersion}, Max version: ${this.maxVersion}, Server version: ${this.serverVersion}`) if (semver.gt(this.serverVersion, this.maxVersion)) { try { @@ -63,6 +66,11 @@ class MigrationManager { async runMigrations() { if (!this.initialized) throw new Error('MigrationManager is not initialized. Call init() first.') + if (this.isDatabaseNew) { + Logger.info('[MigrationManager] Database is new. Skipping migrations.') + return + } + const versionCompare = semver.compare(this.serverVersion, this.databaseVersion) if (versionCompare == 0) { Logger.info('[MigrationManager] Database is already up to date.') @@ -180,7 +188,15 @@ class MigrationManager { async checkOrCreateMigrationsMetaTable() { const queryInterface = this.sequelize.getQueryInterface() - if (!(await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE))) { + let migrationsMetaTableExists = await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE) + + if (this.isDatabaseNew && migrationsMetaTableExists) { + // This can happen if database was initialized with force: true + await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE) + migrationsMetaTableExists = false + } + + if (!migrationsMetaTableExists) { await queryInterface.createTable(MigrationManager.MIGRATIONS_META_TABLE, { key: { type: DataTypes.STRING, @@ -192,9 +208,10 @@ class MigrationManager { } }) await this.sequelize.query("INSERT INTO :migrationsMeta (key, value) VALUES ('version', :version), ('maxVersion', '0.0.0')", { - replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, + replacements: { version: this.isDatabaseNew ? this.serverVersion : '0.0.0', migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, type: Sequelize.QueryTypes.INSERT }) + Logger.debug(`[MigrationManager] Created migrationsMeta table: "${MigrationManager.MIGRATIONS_META_TABLE}"`) } } @@ -219,6 +236,7 @@ class MigrationManager { await fs.copy(sourceFile, targetFile) // Asynchronously copy the files }) ) + Logger.debug(`[MigrationManager] Copied migrations to the config directory: "${this.migrationsDir}"`) } /** diff --git a/test/server/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js index ae28c0d118..ae94cd75cc 100644 --- a/test/server/managers/MigrationManager.test.js +++ b/test/server/managers/MigrationManager.test.js @@ -31,7 +31,7 @@ describe('MigrationManager', () => { down: sinon.stub() } sequelizeStub.getQueryInterface.returns({}) - migrationManager = new MigrationManager(sequelizeStub, configPath) + migrationManager = new MigrationManager(sequelizeStub, false, configPath) migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves() migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves() migrationManager.updateMaxVersion = sinon.stub().resolves() @@ -131,6 +131,21 @@ describe('MigrationManager', () => { expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true }) + it('should log that migrations will be skipped if database is new', async () => { + // Arrange + migrationManager.isDatabaseNew = true + migrationManager.initialized = true + + // Act + await migrationManager.runMigrations() + + // Assert + expect(loggerInfoStub.calledWith(sinon.match('Database is new. Skipping migrations.'))).to.be.true + expect(migrationManager.initUmzug.called).to.be.false + expect(umzugStub.up.called).to.be.false + expect(umzugStub.down.called).to.be.false + }) + it('should log that no migrations are needed if serverVersion equals databaseVersion', async () => { // Arrange migrationManager.serverVersion = '1.2.0' @@ -181,7 +196,7 @@ describe('MigrationManager', () => { // Create a migrationsMeta table and populate it with version and maxVersion await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))') await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')") - const migrationManager = new MigrationManager(sequelize, configPath) + const migrationManager = new MigrationManager(sequelize, false, configPath) migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves() // Act @@ -195,7 +210,7 @@ describe('MigrationManager', () => { it('should create the migrationsMeta table if it does not exist and fetch versions from it', async () => { // Arrange const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) - const migrationManager = new MigrationManager(sequelize, configPath) + const migrationManager = new MigrationManager(sequelize, false, configPath) migrationManager.serverVersion = serverVersion // Act @@ -208,6 +223,42 @@ describe('MigrationManager', () => { value: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false } }) expect(migrationManager.maxVersion).to.equal('0.0.0') + expect(migrationManager.databaseVersion).to.equal('0.0.0') + }) + + it('should create the migrationsMeta with databaseVersion=serverVersion if database is new', async () => { + // Arrange + const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + const migrationManager = new MigrationManager(sequelize, true, configPath) + migrationManager.serverVersion = serverVersion + + // Act + await migrationManager.fetchVersionsFromDatabase() + + // Assert + const tableDescription = await sequelize.getQueryInterface().describeTable('migrationsMeta') + expect(tableDescription).to.deep.equal({ + key: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }, + value: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false } + }) + expect(migrationManager.maxVersion).to.equal('0.0.0') + expect(migrationManager.databaseVersion).to.equal(serverVersion) + }) + + it('should re-create the migrationsMeta table if it existed and database is new (Database force=true)', async () => { + // Arrange + const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + // Create a migrationsMeta table and populate it with version and maxVersion + await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))') + await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')") + const migrationManager = new MigrationManager(sequelize, true, configPath) + migrationManager.serverVersion = serverVersion + + // Act + await migrationManager.fetchVersionsFromDatabase() + + // Assert + expect(migrationManager.maxVersion).to.equal('0.0.0') expect(migrationManager.databaseVersion).to.equal(serverVersion) }) @@ -215,7 +266,7 @@ describe('MigrationManager', () => { // Arrange const sequelizeStub = sinon.createStubInstance(Sequelize) sequelizeStub.query.rejects(new Error('Database query failed')) - const migrationManager = new MigrationManager(sequelizeStub, configPath) + const migrationManager = new MigrationManager(sequelizeStub, false, configPath) migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves() // Act @@ -236,7 +287,7 @@ describe('MigrationManager', () => { // Create a migrationsMeta table and populate it with version and maxVersion await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))') await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')") - const migrationManager = new MigrationManager(sequelize, configPath) + const migrationManager = new MigrationManager(sequelize, false, configPath) migrationManager.serverVersion = '1.2.0' // Act @@ -253,7 +304,7 @@ describe('MigrationManager', () => { describe('extractVersionFromTag', () => { it('should return null if tag is not provided', () => { // Arrange - const migrationManager = new MigrationManager(sequelizeStub, configPath) + const migrationManager = new MigrationManager(sequelizeStub, false, configPath) // Act const result = migrationManager.extractVersionFromTag() @@ -264,7 +315,7 @@ describe('MigrationManager', () => { it('should return null if tag does not match the version format', () => { // Arrange - const migrationManager = new MigrationManager(sequelizeStub, configPath) + const migrationManager = new MigrationManager(sequelizeStub, false, configPath) const tag = 'invalid-tag' // Act @@ -276,7 +327,7 @@ describe('MigrationManager', () => { it('should extract the version from the tag', () => { // Arrange - const migrationManager = new MigrationManager(sequelizeStub, configPath) + const migrationManager = new MigrationManager(sequelizeStub, false, configPath) const tag = 'v1.2.3' // Act @@ -290,7 +341,7 @@ describe('MigrationManager', () => { describe('copyMigrationsToConfigDir', () => { it('should copy migrations to the config directory', async () => { // Arrange - const migrationManager = new MigrationManager(sequelizeStub, configPath) + const migrationManager = new MigrationManager(sequelizeStub, false, configPath) migrationManager.migrationsDir = path.join(configPath, 'migrations') const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations') const targetDir = migrationManager.migrationsDir @@ -313,7 +364,7 @@ describe('MigrationManager', () => { it('should throw an error if copying the migrations fails', async () => { // Arrange - const migrationManager = new MigrationManager(sequelizeStub, configPath) + const migrationManager = new MigrationManager(sequelizeStub, false, configPath) migrationManager.migrationsDir = path.join(configPath, 'migrations') const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations') const targetDir = migrationManager.migrationsDir @@ -484,7 +535,7 @@ describe('MigrationManager', () => { const readdirStub = sinon.stub(fs, 'readdir').resolves(['v1.0.0-migration.js', 'v1.10.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js']) const readFileSyncStub = sinon.stub(fs, 'readFileSync').returns('module.exports = { up: () => {}, down: () => {} }') const umzugStorage = memoryStorage() - migrationManager = new MigrationManager(sequelizeStub, configPath) + migrationManager = new MigrationManager(sequelizeStub, false, configPath) migrationManager.migrationsDir = path.join(configPath, 'migrations') const resolvedMigrationNames = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js'] const resolvedMigrationPaths = resolvedMigrationNames.map((name) => path.resolve(path.join(migrationManager.migrationsDir, name))) From 21c77dccce3acffcd66c38365bcd00d4274f7edf Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 14 Sep 2024 13:05:21 +0300 Subject: [PATCH 103/539] Add server migration scripts to pkg assets --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index da10e00012..70cf40c2d7 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "pkg": { "assets": [ "client/dist/**/*", - "node_modules/sqlite3/lib/binding/**/*.node" + "node_modules/sqlite3/lib/binding/**/*.node", + "server/migrations/*.js" ], "scripts": [ "prod.js", From 8ae62da1389cd4fe87ba4327e9d76a66237b087a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Sep 2024 10:40:01 -0500 Subject: [PATCH 104/539] Update migration unit test name --- test/server/migrations/v2.13.5-series-column-unique.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index 6d55b62932..ed950a01f0 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -7,7 +7,7 @@ const { query } = require('express') const { logger } = require('sequelize/lib/utils/logger') const e = require('express') -describe('migration_example', () => { +describe('migration-v2.13.5-series-column-unique', () => { let sequelize let queryInterface let loggerInfoStub From 868659a2f1260d350694ffeda0d59c58d2aa2769 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 11:44:19 -0700 Subject: [PATCH 105/539] Add: unique constraint on bookseries table --- .../v2.13.5-series-column-unique.test.js | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index ed950a01f0..d98fb4f79c 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -48,11 +48,16 @@ describe('migration-v2.13.5-series-column-unique', () => { createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false } }) - await queryInterface.createTable('BookSeries', { - id: { type: Sequelize.UUID, primaryKey: true }, - bookId: { type: Sequelize.UUID, allowNull: false }, - seriesId: { type: Sequelize.UUID, allowNull: false } - }) + // Create a table for BookSeries, with a unique constraint of bookId and seriesId + await queryInterface.createTable( + 'BookSeries', + { + id: { type: Sequelize.UUID, primaryKey: true }, + bookId: { type: Sequelize.UUID, allowNull: false }, + seriesId: { type: Sequelize.UUID, allowNull: false } + }, + { uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } } + ) // Set UUIDs for the tests series1Id = 'fc086255-3fd2-4a95-8a28-840d9206501b' series2Id = '70f46ac2-ee48-4b3c-9822-933cc15c29bd' @@ -199,11 +204,16 @@ describe('migration-v2.13.5-series-column-unique', () => { createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false } }) - await queryInterface.createTable('BookSeries', { - id: { type: Sequelize.UUID, primaryKey: true }, - bookId: { type: Sequelize.UUID, allowNull: false }, - seriesId: { type: Sequelize.UUID, allowNull: false } - }) + // Create a table for BookSeries, with a unique constraint of bookId and seriesId + await queryInterface.createTable( + 'BookSeries', + { + id: { type: Sequelize.UUID, primaryKey: true }, + bookId: { type: Sequelize.UUID, allowNull: false }, + seriesId: { type: Sequelize.UUID, allowNull: false } + }, + { uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } } + ) }) it('should not have unique constraint on series name and libraryId', async () => { await up({ context: { queryInterface, logger: Logger } }) From fa451f362b0a83390bbe9dbc3b30abea0af21d8d Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 12:11:31 -0700 Subject: [PATCH 106/539] Add: tests for one book in duplicate series --- .../v2.13.5-series-column-unique.test.js | 102 ++++++++++++++++-- 1 file changed, 95 insertions(+), 7 deletions(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index d98fb4f79c..19dc4a8e45 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -53,6 +53,7 @@ describe('migration-v2.13.5-series-column-unique', () => { 'BookSeries', { id: { type: Sequelize.UUID, primaryKey: true }, + sequence: { type: Sequelize.STRING, allowNull: true }, bookId: { type: Sequelize.UUID, allowNull: false }, seriesId: { type: Sequelize.UUID, allowNull: false } }, @@ -93,9 +94,9 @@ describe('migration-v2.13.5-series-column-unique', () => { ]) // Add some entries to the BookSeries table await queryInterface.bulkInsert('BookSeries', [ - { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }, - { id: bookSeries3Id, bookId: book3Id, seriesId: series3Id } + { id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id } ]) await up({ context: { queryInterface, logger: Logger } }) @@ -112,13 +113,13 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id }) expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id }) - const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(bookSeries).to.have.length(3) - expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) - expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) - expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book2Id, seriesId: series2Id }) + expect(bookSeries).to.deep.include({ id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id }) }) - it('upgrade with duplicate series', async () => { + it('upgrade with duplicate series and no sequence', async () => { // Add some entries to the Series table using the UUID for the ids await queryInterface.bulkInsert('Series', [ { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, @@ -193,6 +194,93 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) }) + it('upgrade with one book in two of the same series, both sequence are null', async () => { + // Create two different series with the same name in the same library + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Create a book that is in both series + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book1Id, seriesId: series2Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(1) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(1) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: null, bookId: book1Id, seriesId: series1Id }) + }) + it('upgrade with one book in two of the same series, one sequence is null', async () => { + // Create two different series with the same name in the same library + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Create a book that is in both series + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book1Id, seriesId: series2Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(1) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(1) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }) + }) + it('upgrade with one book in two of the same series, both sequence are not null', async () => { + // Create two different series with the same name in the same library + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Create a book that is in both series + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series2Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(1) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(1) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1, 2', bookId: book1Id, seriesId: series1Id }) + }) }) describe('down', () => { From a545aa5c394b8c624ad54c43a3281322d77d960f Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 9 Sep 2024 23:00:28 +0000 Subject: [PATCH 107/539] Translated using Weblate (French) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index 6b01995a9b..bfca42a1a3 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -98,6 +98,7 @@ "ButtonStats": "Statistiques", "ButtonSubmit": "Soumettre", "ButtonTest": "Test", + "ButtonUnlinkOpenId": "Dissocier OpenID", "ButtonUpload": "Téléverser", "ButtonUploadBackup": "Téléverser une sauvegarde", "ButtonUploadCover": "Téléverser une couverture", From a19bc4b4e43179f979af72f805c769bd004a21d2 Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Tue, 10 Sep 2024 02:46:43 +0000 Subject: [PATCH 108/539] Translated using Weblate (German) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 78 +++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 4a7e6ce9ce..9aec24f364 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -19,7 +19,7 @@ "ButtonChooseFiles": "Wähle eine Datei", "ButtonClearFilter": "Filter löschen", "ButtonCloseFeed": "Feed schließen", - "ButtonCloseSession": "Offene Session schließen", + "ButtonCloseSession": "Offene Sitzung schließen", "ButtonCollections": "Sammlungen", "ButtonConfigureScanner": "Scannereinstellungen", "ButtonCreate": "Erstellen", @@ -51,7 +51,7 @@ "ButtonNext": "Vor", "ButtonNextChapter": "Nächstes Kapitel", "ButtonNextItemInQueue": "Das nächste Element in der Warteschlange", - "ButtonOk": "Ok", + "ButtonOk": "OK", "ButtonOpenFeed": "Feed öffnen", "ButtonOpenManager": "Manager öffnen", "ButtonPause": "Pausieren", @@ -115,7 +115,7 @@ "HeaderAdvanced": "Erweitert", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAudioTracks": "Audiodateien", - "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools", + "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungswerkzeuge", "HeaderAuthentication": "Authentifizierung", "HeaderBackups": "Sicherungen", "HeaderChangePassword": "Passwort ändern", @@ -125,13 +125,13 @@ "HeaderCollectionItems": "Sammlungseinträge", "HeaderCover": "Titelbild", "HeaderCurrentDownloads": "Aktuelle Downloads", - "HeaderCustomMessageOnLogin": "Benutzerdefinierte Nachricht für den Login", - "HeaderCustomMetadataProviders": "Benutzerdefinierte Metadata Anbieter", + "HeaderCustomMessageOnLogin": "Benutzerdefinierte Nachricht für die Anmeldung", + "HeaderCustomMetadataProviders": "Benutzerdefinierte Metadatenanbieter", "HeaderDetails": "Details", "HeaderDownloadQueue": "Download Warteschlange", "HeaderEbookFiles": "E-Buch-Dateien", - "HeaderEmail": "Email", - "HeaderEmailSettings": "Email Einstellungen", + "HeaderEmail": "E-Mail", + "HeaderEmailSettings": "E-Mail-Einstellungen", "HeaderEpisodes": "Episoden", "HeaderEreaderDevices": "E-Reader Geräte", "HeaderEreaderSettings": "Einstellungen zum Lesen", @@ -158,12 +158,12 @@ "HeaderNewAccount": "Neues Konto", "HeaderNewLibrary": "Neue Bibliothek", "HeaderNotificationCreate": "Benachrichtigung erstellen", - "HeaderNotificationUpdate": "Benachrichtigung updaten", + "HeaderNotificationUpdate": "Benachrichtigung bearbeiten", "HeaderNotifications": "Benachrichtigungen", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", "HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOtherFiles": "Sonstige Dateien", - "HeaderPasswordAuthentication": "Passwort Authentifizierung", + "HeaderPasswordAuthentication": "Passwortauthentifizierung", "HeaderPermissions": "Berechtigungen", "HeaderPlayerQueue": "Player Warteschlange", "HeaderPlayerSettings": "Player Einstellungen", @@ -245,7 +245,7 @@ "LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn du bereits mehrere Sicherungen als die definierte max. Anzahl hast, solltest du diese manuell entfernen.", "LabelBitrate": "Bitrate", "LabelBooks": "Bücher", - "LabelButtonText": "Button Text", + "LabelButtonText": "Knopftext", "LabelByAuthor": "von {0}", "LabelChangePassword": "Passwort ändern", "LabelChannels": "Kanäle", @@ -293,13 +293,13 @@ "LabelEbook": "E-Buch", "LabelEbooks": "E-Bücher", "LabelEdit": "Bearbeiten", - "LabelEmail": "Email", - "LabelEmailSettingsFromAddress": "Von Adresse", + "LabelEmail": "E-Mail", + "LabelEmailSettingsFromAddress": "Sender", "LabelEmailSettingsRejectUnauthorized": "Nicht autorisierte Zertifikate ablehnen", - "LabelEmailSettingsRejectUnauthorizedHelp": "Durch das Deaktivieren der SSL-Zertifikatsüberprüfung kann deine Verbindung Sicherheitsrisiken wie Man-in-the-Middle-Angriffen ausgesetzt sein. Deaktiviere diese Option nur, wenn due die Auswirkungen verstehst und dem Mailserver vertraust, mit dem eine Verbindung hergestellt wird.", + "LabelEmailSettingsRejectUnauthorizedHelp": "Durch das Deaktivieren der SSL-Zertifikatsüberprüfung kann deine Verbindung Sicherheitsrisiken wie Man-in-the-Middle-Angriffen ausgesetzt sein. Deaktiviere diese Option nur, wenn due die Auswirkungen verstehst und dem E-Mail-Server vertraust, mit dem eine Verbindung hergestellt wird.", "LabelEmailSettingsSecure": "Sicher", - "LabelEmailSettingsSecureHelp": "Wenn \"an\", verwendet die Verbindung TLS, wenn du eine Verbindung zum Server herstellst. Bei \"aus\" wird TLS verwendet, wenn der Server die STARTTLS-Erweiterung unterstützt. In den meisten Fällen solltest du diesen Wert auf \"an\" schalten, wenn du eine Verbindung zu Port 465 herstellst. Für Port 587 oder 25 behalte den Wert \"aus\" bei. (von nodemailer.com/smtp/#authentication)", - "LabelEmailSettingsTestAddress": "Test Adresse", + "LabelEmailSettingsSecureHelp": "Wenn an, verwendet die Verbindung TLS, wenn du eine Verbindung zum Server herstellst. Bei „aus“ wird TLS verwendet, wenn der Server die STARTTLS-Erweiterung unterstützt. In den meisten Fällen solltest du diesen Wert auf „an“ schalten, wenn du eine Verbindung zu Port 465 herstellst. Für Port 587 oder 25 behalte den Wert „aus“ bei. (von nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Test-Adresse", "LabelEmbeddedCover": "Eingebettetes Cover", "LabelEnable": "Aktivieren", "LabelEnd": "Ende", @@ -315,7 +315,7 @@ "LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)", "LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)", "LabelExportOPML": "OPML exportieren", - "LabelFeedURL": "Feed URL", + "LabelFeedURL": "Feed-URL", "LabelFetchingMetadata": "Abholen der Metadaten", "LabelFile": "Datei", "LabelFileBirthtime": "Datei erstellt", @@ -338,11 +338,11 @@ "LabelGenre": "Kategorie", "LabelGenres": "Kategorien", "LabelHardDeleteFile": "Datei dauerhaft löschen", - "LabelHasEbook": "E-Book verfügbar", - "LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar", + "LabelHasEbook": "E-Buch verfügbar", + "LabelHasSupplementaryEbook": "Ergänzendes E-Buch verfügbar", "LabelHideSubtitles": "Untertitel ausblenden", "LabelHighestPriority": "Höchste Priorität", - "LabelHost": "Host", + "LabelHost": "Anbieter", "LabelHour": "Stunde", "LabelHours": "Stunden", "LabelIcon": "Symbol", @@ -371,13 +371,13 @@ "LabelLastSeen": "Zuletzt gesehen", "LabelLastTime": "Letztes Mal", "LabelLastUpdate": "Letzte Aktualisierung", - "LabelLayout": "Layout", + "LabelLayout": "Ansicht", "LabelLayoutSinglePage": "Eine Seite", "LabelLayoutSplitPage": "Geteilte Seite", "LabelLess": "Weniger", "LabelLibrariesAccessibleToUser": "Für Benutzer zugängliche Bibliotheken", "LabelLibrary": "Bibliothek", - "LabelLibraryFilterSublistEmpty": "Nr. {0}", + "LabelLibraryFilterSublistEmpty": "Keine {0}", "LabelLibraryItem": "Bibliothekseintrag", "LabelLibraryName": "Bibliotheksname", "LabelLimit": "Begrenzung", @@ -399,10 +399,10 @@ "LabelMinute": "Minute", "LabelMinutes": "Minuten", "LabelMissing": "Fehlend", - "LabelMissingEbook": "E-Book fehlt", - "LabelMissingSupplementaryEbook": "Ergänzendes E-Book fehlt", + "LabelMissingEbook": "E-Buch fehlt", + "LabelMissingSupplementaryEbook": "Ergänzendes E-Buch fehlt", "LabelMobileRedirectURIs": "Erlaubte Weiterleitungs-URIs für die mobile App", - "LabelMobileRedirectURIsDescription": "Dies ist eine Whitelist gültiger Umleitungs-URIs für mobile Apps. Der Standardwert ist audiobookshelf://oauth, den du entfernen oder durch zusätzliche URIs für die Integration von Drittanbieter-Apps ergänzen kannst. Die Verwendung eines Sternchens (*) als alleiniger Eintrag erlaubt jede URI.", + "LabelMobileRedirectURIsDescription": "Dies ist eine weiße Liste gültiger Umleitungs-URIs für mobile Apps. Der Standardwert ist audiobookshelf://oauth, den du entfernen oder durch zusätzliche URIs für die Integration von Drittanbieter-Apps ergänzen kannst. Die Verwendung eines Sternchens (*) als alleiniger Eintrag erlaubt jede URI.", "LabelMore": "Mehr", "LabelMoreInfo": "Mehr Infos", "LabelName": "Name", @@ -419,7 +419,7 @@ "LabelNotFinished": "Nicht beendet", "LabelNotStarted": "Nicht begonnen", "LabelNotes": "Notizen", - "LabelNotificationAppriseURL": "Apprise URL(s)", + "LabelNotificationAppriseURL": "Apprise-URL(s)", "LabelNotificationAvailableVariables": "Verfügbare Variablen", "LabelNotificationBodyTemplate": "Textvorlage", "LabelNotificationEvent": "Benachrichtigungs Event", @@ -457,7 +457,7 @@ "LabelPort": "Port", "LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)", "LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird", - "LabelPrimaryEbook": "Primäres E-Book", + "LabelPrimaryEbook": "Primäres E-Buch", "LabelProgress": "Fortschritt", "LabelProvider": "Anbieter", "LabelProviderAuthorizationValue": "Autorisierungsheader-Wert", @@ -468,15 +468,15 @@ "LabelPublishers": "Herausgeber", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", - "LabelRSSFeedOpen": "RSS Feed Offen", + "LabelRSSFeedOpen": "RSS Feed offen", "LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort", - "LabelRSSFeedURL": "RSS Feed URL", + "LabelRSSFeedURL": "RSS-Feed-URL", "LabelRandomly": "Zufällig", "LabelReAddSeriesToContinueListening": "Serien erneut zur Fortsetzungsliste hinzufügen", "LabelRead": "Lesen", "LabelReadAgain": "Noch einmal Lesen", - "LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen", + "LabelReadEbookWithoutProgress": "E-Buch lesen und Fortschritt verwerfen", "LabelRecentSeries": "Aktuelle Serien", "LabelRecentlyAdded": "Kürzlich hinzugefügt", "LabelRecommended": "Empfohlen", @@ -493,7 +493,7 @@ "LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", "LabelSelectUsers": "Benutzer auswählen", - "LabelSendEbookToDevice": "E-Book senden an...", + "LabelSendEbookToDevice": "E-Buch senden an …", "LabelSequence": "Reihenfolge", "LabelSeries": "Serien", "LabelSeriesName": "Serienname", @@ -502,7 +502,7 @@ "LabelSetEbookAsPrimary": "Als Hauptbuch setzen", "LabelSetEbookAsSupplementary": "Als Ergänzung setzen", "LabelSettingsAudiobooksOnly": "Nur Hörbücher", - "LabelSettingsAudiobooksOnlyHelp": "Wenn du diese Einstellung aktivierst, werden E-Book-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Books festgelegt", + "LabelSettingsAudiobooksOnlyHelp": "Wenn du diese Einstellung aktivierst, werden E-Buch-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Bücher festgelegt", "LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden", "LabelSettingsChromecastSupport": "Chromecastunterstützung", "LabelSettingsDateFormat": "Datumsformat", @@ -595,7 +595,7 @@ "LabelToolsEmbedMetadataDescription": "Bettet die Metadaten einschließlich des Titelbildes und der Kapitel in die Audiodatein ein.", "LabelToolsMakeM4b": "M4B-Datei erstellen", "LabelToolsMakeM4bDescription": "Erstellt eine M4B-Datei (Endung \".m4b\") welche mehrere mp3-Dateien in einer einzigen Datei inkl. derer Metadaten (Beschreibung, Titelbild, Kapitel, ...) zusammenfasst. M4B-Datei können darüber hinaus Lesezeichen speichern und mit einem Abspielschutz (Passwort) versehen werden.", - "LabelToolsSplitM4b": "M4B in MP3's aufteilen", + "LabelToolsSplitM4b": "M4B in MP3s aufteilen", "LabelToolsSplitM4bDescription": "Erstellt aus einer mit Metadaten und nach Kapiteln aufgeteilten M4B-Datei seperate MP3's mit eingebetteten Metadaten, Coverbild und Kapiteln.", "LabelTotalDuration": "Gesamtdauer", "LabelTotalTimeListened": "Gehörte Gesamtzeit", @@ -658,7 +658,7 @@ "MessageCheckingCron": "Überprüfe Cron...", "MessageConfirmCloseFeed": "Feed wird geschlossen! Bist du dir sicher?", "MessageConfirmDeleteBackup": "Sicherung für {0} wird gelöscht! Bist du dir sicher?", - "MessageConfirmDeleteDevice": "Möchtest Du das E-Reader-Gerät „{0}“ wirklich löschen?", + "MessageConfirmDeleteDevice": "Möchtest du das Lesegerät „{0}“ wirklich löschen?", "MessageConfirmDeleteFile": "Datei wird vom System gelöscht! Bist du dir sicher?", "MessageConfirmDeleteLibrary": "Bibliothek \"{0}\" wird dauerhaft gelöscht! Bist du dir sicher?", "MessageConfirmDeleteLibraryItem": "Bibliothekselement wird aus der Datenbank + Festplatte gelöscht? Bist du dir sicher?", @@ -693,14 +693,14 @@ "MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.", "MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".", "MessageConfirmResetProgress": "Möchtest du Ihren Fortschritt wirklich zurücksetzen?", - "MessageConfirmSendEbookToDevice": "{0} E-Book \"{1}\" wird auf das Gerät \"{2}\" gesendet! Bist du dir sicher?", + "MessageConfirmSendEbookToDevice": "{0} E-Buch „{1}“ wird auf das Gerät „{2}“ gesendet! Bist du dir sicher?", "MessageConfirmUnlinkOpenId": "Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?", "MessageDownloadingEpisode": "Episode wird heruntergeladen", "MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge", "MessageEmbedFailed": "Einbetten fehlgeschlagen!", "MessageEmbedFinished": "Einbettung abgeschlossen!", "MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen", - "MessageEreaderDevices": "Um die Zustellung von E-Books sicherzustellen, musst du eventuell die oben genannte E-Mail-Adresse als gültigen Absender für jedes unten aufgeführte Gerät hinzufügen.", + "MessageEreaderDevices": "Um die Zustellung von E-Büchern sicherzustellen, musst du eventuell die oben genannte E-Mail-Adresse als gültigen Absender für jedes unten aufgeführte Gerät hinzufügen.", "MessageFeedURLWillBe": "Feed-URL wird {0} sein", "MessageFetching": "Wird abgerufen …", "MessageForceReScanDescription": "Durchsucht alle Dateien erneut, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.", @@ -864,7 +864,7 @@ "ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden", "ToastDeviceNameAlreadyExists": "E-Reader-Gerät mit diesem Namen existiert bereits", "ToastDeviceTestEmailFailed": "Senden der Test-E-Mail fehlgeschlagen", - "ToastDeviceTestEmailSuccess": "Test-E-Mail versand", + "ToastDeviceTestEmailSuccess": "Test-E-Mail gesendet", "ToastDeviceUpdateFailed": "Das Gerät konnte nicht aktualisiert werden", "ToastEmailSettingsUpdateFailed": "E-Mail-Einstellungen konnten nicht aktualisiert werden", "ToastEmailSettingsUpdateSuccess": "E-Mail-Einstellungen aktualisiert", @@ -899,7 +899,7 @@ "ToastLibraryScanStarted": "Bibliotheksscan gestartet", "ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen", "ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert", - "ToastNameEmailRequired": "Name und Email sind erforderlich", + "ToastNameEmailRequired": "Name und E-Mail sind erforderlich", "ToastNameRequired": "Name ist erforderlich", "ToastNewUserCreatedFailed": "Fehler beim erstellen des Accounts: \"{ 0}\"", "ToastNewUserCreatedSuccess": "Neuer Account erstellt", @@ -946,8 +946,8 @@ "ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert", "ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek", "ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus", - "ToastSendEbookToDeviceFailed": "E-Book konnte nicht auf Gerät übertragen werden", - "ToastSendEbookToDeviceSuccess": "E-Book an Gerät \"{0}\" gesendet", + "ToastSendEbookToDeviceFailed": "E-Buch konnte nicht auf Gerät übertragen werden", + "ToastSendEbookToDeviceSuccess": "E-Buch an Gerät „{0}“ gesendet", "ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen", "ToastSeriesUpdateSuccess": "Serien aktualisiert", "ToastServerSettingsUpdateFailed": "Die Server-Einstellungen wurden nicht gespeichert", From 662b7d01b833e69061d33e6e7b5df91f6b08ee7f Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Tue, 10 Sep 2024 03:16:08 +0000 Subject: [PATCH 109/539] Translated using Weblate (French) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 70 +++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index bfca42a1a3..a1b5a58e5d 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -51,7 +51,7 @@ "ButtonNext": "Suivant", "ButtonNextChapter": "Chapitre suivant", "ButtonNextItemInQueue": "Élément suivant dans la file d’attente", - "ButtonOk": "Ok", + "ButtonOk": "D’accord", "ButtonOpenFeed": "Ouvrir le flux", "ButtonOpenManager": "Ouvrir le gestionnaire", "ButtonPause": "Pause", @@ -243,7 +243,7 @@ "LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.", "LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver", "LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.", - "LabelBitrate": "Bitrate", + "LabelBitrate": "Débit binaire", "LabelBooks": "Livres", "LabelButtonText": "Texte du bouton", "LabelByAuthor": "par {0}", @@ -656,45 +656,45 @@ "MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre", "MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio", "MessageCheckingCron": "Vérification du cron…", - "MessageConfirmCloseFeed": "Êtes-vous sûr de vouloir fermer ce flux ?", - "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?", - "MessageConfirmDeleteDevice": "Êtes-vous sûr de vouloir supprimer la liseuse « {0} » ?", + "MessageConfirmCloseFeed": "Êtes-vous sûr·e de vouloir fermer ce flux ?", + "MessageConfirmDeleteBackup": "Êtes-vous sûr·e de vouloir supprimer la sauvegarde de « {0} » ?", + "MessageConfirmDeleteDevice": "Êtes-vous sûr·e de vouloir supprimer la liseuse « {0} » ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", - "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", + "MessageConfirmDeleteLibrary": "Êtes-vous sûr·e de vouloir supprimer définitivement la bibliothèque « {0} » ?", "MessageConfirmDeleteLibraryItem": "Cette opération supprimera l’élément de la base de données et de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteLibraryItems": "Cette opération supprimera {0} éléments de la base de données et de votre système de fichiers. Êtes-vous sûr ?", - "MessageConfirmDeleteMetadataProvider": "Êtes-vous sûr de vouloir supprimer le fournisseur de métadonnées personnalisées « {0} » ?", - "MessageConfirmDeleteNotification": "Êtes-vous sûr de vouloir supprimer cette notification ?", - "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", - "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?", - "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?", - "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?", - "MessageConfirmMarkItemFinished": "Êtes-vous sûr de vouloir marquer \"{0}\" comme terminé ?", - "MessageConfirmMarkItemNotFinished": "Êtes-vous sûr de vouloir marquer \"{0}\" comme non terminé ?", - "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?", - "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme non terminés ?", + "MessageConfirmDeleteMetadataProvider": "Êtes-vous sûr·e de vouloir supprimer le fournisseur de métadonnées personnalisées « {0} » ?", + "MessageConfirmDeleteNotification": "Êtes-vous sûr·e de vouloir supprimer cette notification ?", + "MessageConfirmDeleteSession": "Êtes-vous sûr·e de vouloir supprimer cette session ?", + "MessageConfirmForceReScan": "Êtes-vous sûr·e de vouloir lancer une analyse forcée ?", + "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr·e de marquer tous les épisodes comme terminés ?", + "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr·e de vouloir marquer tous les épisodes comme non terminés ?", + "MessageConfirmMarkItemFinished": "Êtes-vous sûr·e de vouloir marquer {0} comme terminé ?", + "MessageConfirmMarkItemNotFinished": "Êtes-vous sûr·e de vouloir marquer {0} comme non terminé ?", + "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr·e de vouloir marquer tous les livres de cette série comme terminées ?", + "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr·e de vouloir marquer tous les livres de cette série comme non terminés ?", "MessageConfirmNotificationTestTrigger": "Déclencher cette notification avec des données de test ?", - "MessageConfirmPurgeCache": "La purge du cache supprimera l’intégralité du répertoire à /metadata/cache.

Êtes-vous sûr de vouloir supprimer le répertoire de cache ?", + "MessageConfirmPurgeCache": "La purge du cache supprimera l’intégralité du répertoire à /metadata/cache.

Êtes-vous sûr·e de vouloir supprimer le répertoire de cache ?", "MessageConfirmPurgeItemsCache": "Purger le cache des éléments supprimera l'ensemble du répertoire /metadata/cache/items.
Êtes-vous sûr ?", "MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

Souhaitez-vous continuer ?", - "MessageConfirmReScanLibraryItems": "Êtes-vous sûr de vouloir re-analyser {0} éléments ?", - "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", - "MessageConfirmRemoveAuthor": "Êtes-vous sûr de vouloir supprimer l’auteur « {0} » ?", - "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", - "MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?", - "MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?", - "MessageConfirmRemoveListeningSessions": "Êtes-vous sûr de vouloir supprimer {0} sessions d’écoute ?", - "MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur « {0} » ?", - "MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?", - "MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les éléments ?", + "MessageConfirmReScanLibraryItems": "Êtes-vous sûr·e de vouloir réanalyser {0} éléments ?", + "MessageConfirmRemoveAllChapters": "Êtes-vous sûr·e de vouloir supprimer tous les chapitres ?", + "MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer l’auteur « {0} » ?", + "MessageConfirmRemoveCollection": "Êtes-vous sûr·e de vouloir supprimer la collection « {0} » ?", + "MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer l’épisode « {0} » ?", + "MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes ?", + "MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions d’écoute ?", + "MessageConfirmRemoveNarrator": "Êtes-vous sûr·e de vouloir supprimer le narrateur « {0} » ?", + "MessageConfirmRemovePlaylist": "Êtes-vous sûr·e de vouloir supprimer la liste de lecture « {0} » ?", + "MessageConfirmRenameGenre": "Êtes-vous sûr·e de vouloir renommer le genre « {0} » en « {1} » pour tous les éléments ?", "MessageConfirmRenameGenreMergeNote": "Information : ce genre existe déjà et sera fusionné.", "MessageConfirmRenameGenreWarning": "Attention ! Un genre similaire avec une casse différente existe déjà « {0} ».", - "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les éléments ?", + "MessageConfirmRenameTag": "Êtes-vous sûr·e de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les éléments ?", "MessageConfirmRenameTagMergeNote": "Information : Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", - "MessageConfirmResetProgress": "Êtes-vous sûr de vouloir réinitialiser votre progression ?", - "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer {0} livre numérique « {1} » à l'appareil « {2} » ?", - "MessageConfirmUnlinkOpenId": "Êtes-vous sûr de vouloir dissocier cet utilisateur d’OpenID ?", + "MessageConfirmResetProgress": "Êtes-vous sûr·e de vouloir réinitialiser votre progression ?", + "MessageConfirmSendEbookToDevice": "Êtes-vous sûr·e de vouloir envoyer {0} livre numérique « {1} » à l'appareil « {2} » ?", + "MessageConfirmUnlinkOpenId": "Êtes-vous sûr·e de vouloir dissocier cet utilisateur d’OpenID ?", "MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans l’ordre correct des pistes", "MessageEmbedFailed": "Échec de l’intégration !", @@ -763,10 +763,10 @@ "MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)", "MessageRemoveFromPlayerQueue": "Supprimer de la liste d’écoute", - "MessageRemoveUserWarning": "Êtes-vous sûr de vouloir supprimer définitivement l’utilisateur « {0} » ?", + "MessageRemoveUserWarning": "Êtes-vous sûr·e de vouloir supprimer définitivement l’utilisateur « {0} » ?", "MessageReportBugsAndContribute": "Signalez des anomalies, demandez des fonctionnalités et contribuez sur", - "MessageResetChaptersConfirm": "Êtes-vous sûr de vouloir réinitialiser les chapitres et annuler les changements effectués ?", - "MessageRestoreBackupConfirm": "Êtes-vous sûr de vouloir restaurer la sauvegarde créée le", + "MessageResetChaptersConfirm": "Êtes-vous sûr·e de vouloir réinitialiser les chapitres et annuler les changements effectués ?", + "MessageRestoreBackupConfirm": "Êtes-vous sûr·e de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.

Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.

Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageSearchResultsFor": "Résultats de recherche pour", "MessageSelected": "{0} sélectionnés", @@ -905,7 +905,7 @@ "ToastNewUserCreatedSuccess": "Nouveau compte créé", "ToastNewUserLibraryError": "Au moins une bibliothèque est requise", "ToastNewUserPasswordError": "Un mot de passe est requis, seul l’utilisateur root peut avoir un mot de passe vide", - "ToastNewUserTagError": "Au moins un tag est requis", + "ToastNewUserTagError": "Au moins une étiquette est requise", "ToastNewUserUsernameError": "Entrez un nom d’utilisateur", "ToastNoUpdatesNecessary": "Aucune mise à jour nécessaire", "ToastNotificationCreateFailed": "La création de la notification à échouée", From 9d17e9ff489c9461f00fed7b08c6489edaa14bab Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Tue, 10 Sep 2024 03:00:44 +0000 Subject: [PATCH 110/539] Translated using Weblate (Italian) Currently translated at 89.6% (873 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 120 +++++++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 34 deletions(-) diff --git a/client/strings/it.json b/client/strings/it.json index 062b2a108c..792cbfeae8 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Seleziona i File", "ButtonClearFilter": "Elimina filtri", "ButtonCloseFeed": "Chiudi flusso", + "ButtonCloseSession": "Chiudi la sessione aperta", "ButtonCollections": "Raccolte", "ButtonConfigureScanner": "Configura Scanner", "ButtonCreate": "Crea", @@ -28,6 +29,7 @@ "ButtonEdit": "Modifica", "ButtonEditChapters": "Modifica Capitoli", "ButtonEditPodcast": "Modifica Podcast", + "ButtonEnable": "Abilita", "ButtonForceReScan": "Forza Re-Scan", "ButtonFullPath": "Percorso Completo", "ButtonHide": "Nascondi", @@ -46,6 +48,8 @@ "ButtonNevermind": "Ingora", "ButtonNext": "Prossimo", "ButtonNextChapter": "Prossimo Capitolo", + "ButtonNextItemInQueue": "Elemento successivo in coda", + "ButtonOk": "D’accordo", "ButtonOpenFeed": "Apri il flusso", "ButtonOpenManager": "Apri Manager", "ButtonPause": "Pausa", @@ -54,6 +58,7 @@ "ButtonPlaylists": "Playlist", "ButtonPrevious": "Precendente", "ButtonPreviousChapter": "Capitolo Precendente", + "ButtonProbeAudioFile": "Analizza il file audio", "ButtonPurgeAllCache": "Elimina tutta la Cache", "ButtonPurgeItemsCache": "Elimina la Cache selezionata", "ButtonQueueAddItem": "Aggiungi alla Coda", @@ -71,6 +76,7 @@ "ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto", "ButtonRemoveFromContinueReading": "Rimuovi per proseguire la lettura", "ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla", + "ButtonReset": "Ripristina", "ButtonResetToDefault": "Ripristino di default", "ButtonRestore": "Ripristina", "ButtonSave": "Salva", @@ -85,42 +91,48 @@ "ButtonShare": "Condividi", "ButtonShiftTimes": "Ricerca veloce", "ButtonShow": "Mostra", - "ButtonStartM4BEncode": "Inizia L'Encode del M4B", - "ButtonStartMetadataEmbed": "Inizia Incorporo Metadata", + "ButtonStartM4BEncode": "Inizia la codifica del M4B", + "ButtonStartMetadataEmbed": "Inizia i metadati incorporati", "ButtonStats": "Statistische", "ButtonSubmit": "Invia", + "ButtonTest": "Test", "ButtonUpload": "Carica", - "ButtonUploadBackup": "Carica Backup", - "ButtonUploadCover": "Carica Cover", - "ButtonUploadOPMLFile": "Carica File OPML", - "ButtonUserDelete": "Cancella Utente {0}", - "ButtonUserEdit": "Modifica Utente {0}", - "ButtonViewAll": "Mostra Tutto", + "ButtonUploadBackup": "Carica il backup", + "ButtonUploadCover": "Carica una copertina", + "ButtonUploadOPMLFile": "Carica file OPML", + "ButtonUserDelete": "Elimina l'utente {0}", + "ButtonUserEdit": "Modifica l'utente {0}", + "ButtonViewAll": "Mostra tutto", "ButtonYes": "Sì", - "ErrorUploadFetchMetadataAPI": "Errore Recupero metadati", + "ErrorUploadFetchMetadataAPI": "Errore durante il recupero metadati", "ErrorUploadFetchMetadataNoResults": "Impossibile recuperare i metadati: prova a modificate il titolo e/o l'autore", "ErrorUploadLacksTitle": "Deve avere un titolo", + "HeaderAccount": "Account", + "HeaderAddCustomMetadataProvider": "Aggiungi fornitori di metadati personalizzati", "HeaderAdvanced": "Avanzate", "HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica", "HeaderAudioTracks": "Tracce audio", - "HeaderAudiobookTools": "Utilità Audiobook File Management", + "HeaderAudiobookTools": "Strumenti di gestione file audiolibri", "HeaderAuthentication": "Authenticazione", "HeaderBackups": "Backup", - "HeaderChangePassword": "Cambia Password", + "HeaderChangePassword": "Cambia la password", "HeaderChapters": "Capitoli", "HeaderChooseAFolder": "Seleziona la cartella", "HeaderCollection": "Raccolta", "HeaderCollectionItems": "Elementi della raccolta", - "HeaderCurrentDownloads": "Download Correnti", + "HeaderCover": "Copertina", + "HeaderCurrentDownloads": "Scaricamenti correnti", "HeaderCustomMessageOnLogin": "Messaggio personalizzato all'accesso", "HeaderCustomMetadataProviders": "Metadata Providers Personalizzato", "HeaderDetails": "Dettagli", "HeaderDownloadQueue": "Download coda", "HeaderEbookFiles": "File dei libri", - "HeaderEmailSettings": "Impostazioni Email", + "HeaderEmail": "E-mail", + "HeaderEmailSettings": "Impostazioni e-mail", "HeaderEpisodes": "Episodi", "HeaderEreaderDevices": "Dispositivo Ereader", "HeaderEreaderSettings": "Impostazioni lettore", + "HeaderFiles": "File", "HeaderFindChapters": "Trova Capitoli", "HeaderIgnoredFiles": "File Ignorati", "HeaderItemFiles": "Files", @@ -132,6 +144,8 @@ "HeaderLibraryStats": "Statistiche Libreria", "HeaderListeningSessions": "Sessioni di Ascolto", "HeaderListeningStats": "Statistiche di Ascolto", + "HeaderLogin": "Accesso", + "HeaderLogs": "Registri", "HeaderManageGenres": "Gestisci Generi", "HeaderManageTags": "Gestisci Tags", "HeaderMapDetails": "Mappa Dettagli", @@ -140,17 +154,23 @@ "HeaderMetadataToEmbed": "Metadata da incorporare", "HeaderNewAccount": "Nuovo Account", "HeaderNewLibrary": "Nuova Libreria", + "HeaderNotificationCreate": "Crea una notifica", + "HeaderNotificationUpdate": "Aggiornamento della notifica", "HeaderNotifications": "Notifiche", "HeaderOpenIDConnectAuthentication": "Autenticazione OpenID Connect", "HeaderOpenRSSFeed": "Apri il flusso RSS", "HeaderOtherFiles": "Altri File", + "HeaderPasswordAuthentication": "Autenticazione della password", "HeaderPermissions": "Permessi", "HeaderPlayerQueue": "Coda Riproduzione", "HeaderPlayerSettings": "Impostazioni Player", + "HeaderPlaylist": "Playlist", "HeaderPlaylistItems": "Elementi della playlist", "HeaderPodcastsToAdd": "Podcasts da Aggiungere", "HeaderPreviewCover": "Anteprima Cover", + "HeaderRSSFeedGeneral": "Dettagli RSS", "HeaderRSSFeedIsOpen": "RSS Feed è aperto", + "HeaderRSSFeeds": "Flussi RSS", "HeaderRemoveEpisode": "Rimuovi Episodi", "HeaderRemoveEpisodes": "Rimuovi {0} Episodi", "HeaderSavedMediaProgress": "Progressi salvati", @@ -159,8 +179,10 @@ "HeaderSession": "Sessione", "HeaderSetBackupSchedule": "Imposta programmazione Backup", "HeaderSettings": "Impostazioni", + "HeaderSettingsDisplay": "Visualizzazione", "HeaderSettingsExperimental": "Opzioni Sperimentali", "HeaderSettingsGeneral": "Generale", + "HeaderSettingsScanner": "Scanner", "HeaderSleepTimer": "Sveglia", "HeaderStatsLargestItems": "Oggetti Grandi", "HeaderStatsLongestItems": "libri più lunghi (ore)", @@ -182,6 +204,7 @@ "LabelAbridgedUnchecked": "Integrale (non selezionato)", "LabelAccessibleBy": "Accessibile da", "LabelAccountType": "Tipo di Account", + "LabelAccountTypeAdmin": "Amministratore", "LabelAccountTypeGuest": "Ospite", "LabelAccountTypeUser": "Utente", "LabelActivity": "Attività", @@ -190,6 +213,7 @@ "LabelAddToPlaylist": "Aggiungi alla playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist", "LabelAddedAt": "Aggiunto il", + "LabelAddedDate": "{0} aggiunti", "LabelAdminUsersOnly": "Solo utenti Amministratori", "LabelAll": "Tutti", "LabelAllUsers": "Tutti gli Utenti", @@ -216,8 +240,10 @@ "LabelBackupsMaxBackupSizeHelp": "Come protezione contro gli errori di config, i backup falliranno se superano la dimensione configurata.", "LabelBackupsNumberToKeep": "Numero di backup da mantenere", "LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.", + "LabelBitrate": "Velocità di trasmissione", "LabelBooks": "Libri", "LabelButtonText": "Buttone Testo", + "LabelByAuthor": "da {0}", "LabelChangePassword": "Cambia Password", "LabelChannels": "Canali", "LabelChapterTitle": "Titoli dei Capitoli", @@ -225,6 +251,7 @@ "LabelChaptersFound": "Capitoli Trovati", "LabelClickForMoreInfo": "Click per altre Info", "LabelClosePlayer": "Chiudi player", + "LabelCodec": "Codec", "LabelCollapseSeries": "Comprimi Serie", "LabelCollection": "Raccolta", "LabelCollections": "Raccolte", @@ -233,6 +260,7 @@ "LabelContinueListening": "Continua ad Ascoltare", "LabelContinueReading": "Continua la Lettura", "LabelContinueSeries": "Continua serie", + "LabelCover": "Copertina", "LabelCoverImageURL": "Indirizzo della cover URL", "LabelCreatedAt": "Creato A", "LabelCronExpression": "Espressione Cron", @@ -261,12 +289,13 @@ "LabelEbook": "Libro digitale", "LabelEbooks": "Libri digitali", "LabelEdit": "Modifica", + "LabelEmail": "E-mail", "LabelEmailSettingsFromAddress": "Da Indirizzo", "LabelEmailSettingsRejectUnauthorized": "Rifiuta i certificati non autorizzati", "LabelEmailSettingsRejectUnauthorizedHelp": "La disattivazione della convalida del certificato SSL può esporre la tua connessione a rischi per la sicurezza, come attacchi man-in-the-middle. Disattiva questa opzione solo se ne comprendi le implicazioni e ti fidi del server di posta a cui ti stai connettendo.", "LabelEmailSettingsSecure": "Sicuro", "LabelEmailSettingsSecureHelp": "Se vero, la connessione utilizzerà TLS durante la connessione al server. Se false, viene utilizzato TLS se il server supporta l'estensione STARTTLS. Nella maggior parte dei casi impostare questo valore su true se ci si connette alla porta 465. Per la porta 587 o 25 mantenerlo false. (da nodemailer.com/smtp/#authentication)", - "LabelEmailSettingsTestAddress": "Test Indirizzo", + "LabelEmailSettingsTestAddress": "Indirizzo di test", "LabelEmbeddedCover": "Cover Integrata", "LabelEnable": "Abilita", "LabelEnd": "Fine", @@ -274,6 +303,7 @@ "LabelEpisode": "Episodio", "LabelEpisodeTitle": "Titolo Episodio", "LabelEpisodeType": "Tipo Episodio", + "LabelEpisodes": "Episodi", "LabelExample": "Esempio", "LabelExpandSeries": "Espandi Serie", "LabelExplicit": "Esplicito", @@ -282,8 +312,11 @@ "LabelExportOPML": "Esposta OPML", "LabelFeedURL": "URL del flusso", "LabelFetchingMetadata": "Recupero dei metadati", + "LabelFile": "File", "LabelFileBirthtime": "Data di creazione", + "LabelFileBornDate": "Creato {0}", "LabelFileModified": "Ultima modifica", + "LabelFileModifiedDate": "Modificato {0}", "LabelFilename": "Nome del file", "LabelFilterByUser": "Filtro per Utente", "LabelFindEpisodes": "Trova Episodi", @@ -292,6 +325,8 @@ "LabelFolders": "Cartelle", "LabelFontBold": "Grassetto", "LabelFontBoldness": "Grassetto", + "LabelFontFamily": "Famiglia di caratteri", + "LabelFontItalic": "Corsivo", "LabelFontScale": "Dimensione font", "LabelFontStrikethrough": "Barrato", "LabelFormat": "Formato", @@ -302,6 +337,7 @@ "LabelHasSupplementaryEbook": "Ha un libro supplementale", "LabelHideSubtitles": "Nascondi Sottotitoli", "LabelHighestPriority": "Priorità Massima", + "LabelHost": "Host", "LabelHour": "Ora", "LabelHours": "Ore", "LabelIcon": "Icona", @@ -332,15 +368,17 @@ "LabelLastUpdate": "Ultimo Aggiornamento", "LabelLayout": "Disposizione", "LabelLayoutSinglePage": "Pagina singola", - "LabelLayoutSplitPage": "Dividi Pagina", - "LabelLess": "Poco", - "LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti", - "LabelLibrary": "Libreria", - "LabelLibraryItem": "Elementi della Library", - "LabelLibraryName": "Nome Libreria", + "LabelLayoutSplitPage": "Pagina divisa", + "LabelLess": "Meno", + "LabelLibrariesAccessibleToUser": "Biblioteche accessibili all'utente", + "LabelLibrary": "Biblioteca", + "LabelLibraryFilterSublistEmpty": "Nessuno {0}", + "LabelLibraryItem": "Elementi della biblioteca", + "LabelLibraryName": "Nome della biblioteca", "LabelLimit": "Limiti", "LabelLineSpacing": "Interlinea", "LabelListenAgain": "Ascolta ancora", + "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Allarme", "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", "LabelLowestPriority": "Priorità Minima", @@ -351,8 +389,8 @@ "LabelMinute": "Minuto", "LabelMinutes": "Minuti", "LabelMissing": "Altro", - "LabelMissingEbook": "Non ha ebook", - "LabelMissingSupplementaryEbook": "Non ha ebook supplementare", + "LabelMissingEbook": "Non ha libri digitali", + "LabelMissingSupplementaryEbook": "Non ha un libro digitale supplementare", "LabelMobileRedirectURIs": "URI di reindirizzamento mobile consentiti", "LabelMobileRedirectURIsDescription": "Questa è una lista bianca di URI di reindirizzamento validi per le app mobili. Quello predefinito è audiobookshelf://oauth, che puoi rimuovere o integrare con URI aggiuntivi per l'integrazione di app di terze parti. Utilizzando un asterisco (*) poiché l'unica voce consente qualsiasi URI.", "LabelMore": "Molto", @@ -387,6 +425,7 @@ "LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come gruppo. se configurato, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.", "LabelOpenRSSFeed": "Apri RSS Feed", "LabelOverwrite": "Sovrascrivi", + "LabelPassword": "Password", "LabelPath": "Percorso", "LabelPermanent": "Permanente", "LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie", @@ -399,26 +438,32 @@ "LabelPersonalYearReview": "Il tuo anno in rassegna ({0})", "LabelPhotoPathURL": "foto Path/URL", "LabelPlayMethod": "Metodo di riproduzione", + "LabelPlaylists": "Playlist", + "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Area di ricerca podcast", "LabelPodcastType": "Tipo di Podcast", + "LabelPodcasts": "Podcast", "LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)", "LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google", - "LabelPrimaryEbook": "Libri Principlae", + "LabelPrimaryEbook": "Libro principale", "LabelProgress": "Cominciati", + "LabelProvider": "Fornitore", "LabelPubDate": "Data di pubblicazione", "LabelPublishYear": "Anno di pubblicazione", + "LabelPublishedDate": "{0} pubblicati", "LabelPublisher": "Editore", "LabelPublishers": "Editori", "LabelRSSFeedCustomOwnerEmail": "E-mail del proprietario personalizzato", "LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato", - "LabelRSSFeedOpen": "RSS Feed Aperto", + "LabelRSSFeedOpen": "Flusso RSS aperto", "LabelRSSFeedPreventIndexing": "Impedisci l'indicizzazione", "LabelRSSFeedSlug": "Parole chiave del flusso RSS", + "LabelRSSFeedURL": "URL del flusso RSS", "LabelRandomly": "Casualmente", "LabelReAddSeriesToContinueListening": "Aggiungi di nuovo la serie per continuare ad ascoltare", "LabelRead": "Leggi", "LabelReadAgain": "Leggi ancora", - "LabelReadEbookWithoutProgress": "Leggi l'ebook senza mantenere i progressi", + "LabelReadEbookWithoutProgress": "Leggi il libro senza mantenere i progressi", "LabelRecentSeries": "Serie recenti", "LabelRecentlyAdded": "Aggiunti recentemente", "LabelRecommended": "Raccomandati", @@ -435,7 +480,7 @@ "LabelSelectAllEpisodes": "Seleziona tutti gli Episodi", "LabelSelectEpisodesShowing": "Selezionati {0} episodi da visualizzare", "LabelSelectUsers": "Selezione Utenti", - "LabelSendEbookToDevice": "Invia ebook a...", + "LabelSendEbookToDevice": "Invia il libro a...", "LabelSequence": "Sequenza", "LabelSeries": "Serie", "LabelSeriesName": "Nome Serie", @@ -444,7 +489,7 @@ "LabelSetEbookAsPrimary": "Imposta come primario", "LabelSetEbookAsSupplementary": "Imposta come suplementare", "LabelSettingsAudiobooksOnly": "Solo Audiolibri", - "LabelSettingsAudiobooksOnlyHelp": "L'abilitazione di questa impostazione ignorerà i file di ebook a meno che non si trovino all'interno di una cartella di audiolibri, nel qual caso verranno impostati come ebook supplementari", + "LabelSettingsAudiobooksOnlyHelp": "L'abilitazione di questa impostazione ignorerà i file di libro digitale a meno che non si trovino all'interno di una cartella di audiolibri, nel qual caso verranno impostati come libri digitali supplementari", "LabelSettingsBookshelfViewHelp": "Design con scaffali in legno", "LabelSettingsChromecastSupport": "Supporto a Chromecast", "LabelSettingsDateFormat": "Formato Data", @@ -517,6 +562,7 @@ "LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti", "LabelTasks": "Processi in esecuzione", "LabelTextEditorBulletedList": "Elenco puntato", + "LabelTextEditorLink": "Collegamento", "LabelTextEditorNumberedList": "Elenco Numerato", "LabelTextEditorUnlink": "Scollega", "LabelTheme": "Tema", @@ -536,7 +582,7 @@ "LabelToolsEmbedMetadataDescription": "Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.", "LabelToolsMakeM4b": "Crea un file M4B", "LabelToolsMakeM4bDescription": "Genera un file audiolibro M4B con metadati incorporati, immagine di copertina e capitoli.", - "LabelToolsSplitM4b": "Converti M4B in MP3's", + "LabelToolsSplitM4b": "Converti M4B in MP3", "LabelToolsSplitM4bDescription": "Crea MP3 da un M4B diviso per capitoli con metadati incorporati, immagine di copertina e capitoli.", "LabelTotalDuration": "Durata Totale", "LabelTotalTimeListened": "Tempo totale di Ascolto", @@ -550,6 +596,7 @@ "LabelUnabridged": "Integrale", "LabelUndo": "Annulla", "LabelUnknown": "Sconosciuto", + "LabelUnknownPublishDate": "Data di pubblicazione sconosciuta", "LabelUpdateCover": "Aggiornamento Cover", "LabelUpdateCoverHelp": "Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza", "LabelUpdateDetails": "Dettagli Aggiornamento", @@ -568,6 +615,7 @@ "LabelViewChapters": "Visualizza i Capitoli", "LabelViewPlayerSettings": "Mostra Impostazioni player", "LabelViewQueue": "Visualizza coda", + "LabelVolume": "Volume", "LabelWeekdaysToRun": "Giorni feriali da eseguire", "LabelXBooks": "{0} libri", "LabelXItems": "{0} oggetti", @@ -597,14 +645,18 @@ "MessageCheckingCron": "Controllo cron...", "MessageConfirmCloseFeed": "Sei sicuro di voler chiudere questo feed?", "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", + "MessageConfirmDeleteDevice": "Sei sicuro/sicura di voler eliminare il lettore di libri {0}?", "MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", "MessageConfirmDeleteLibraryItem": "l'elemento della libreria dal database e dal file system. Sei sicuro?", "MessageConfirmDeleteLibraryItems": "Ciò eliminerà {0} elementi della libreria dal database e dal file system. Sei sicuro?", + "MessageConfirmDeleteMetadataProvider": "Sei sicuro/sicura di voler eliminare il fornitore di metadati personalizzato {0}?", + "MessageConfirmDeleteNotification": "Sei sicuro/sicura di voler eliminare questa notifica?", "MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?", "MessageConfirmMarkAllEpisodesNotFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come non completati?", + "MessageConfirmMarkItemFinished": "Sei sicuro/sicura di voler segnare {0} come finito?", "MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?", "MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?", "MessageConfirmPurgeCache": "L'eliminazione della cache eliminerà l'intera directory dei /metadata/cache.

Sei sicuro di voler rimuovere la directory della cache?", @@ -622,16 +674,16 @@ "MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?", "MessageConfirmRenameGenreMergeNote": "Note: Questo genere esiste già quindi verra unito.", "MessageConfirmRenameGenreWarning": "Avvertimento! Esiste già un genere simile con un nome simile \"{0}\".", - "MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?", - "MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.", + "MessageConfirmRenameTag": "Sei sicuro/sicura di voler rinominare l'etichetta \"{0}\" in \"{1}\" per tutti gli oggetti?", + "MessageConfirmRenameTagMergeNote": "Nota: Questa etichetta esiste già e verrà unito nella vecchia.", "MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".", - "MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?", + "MessageConfirmSendEbookToDevice": "Sei sicuro/sicura di voler inviare {0} libro «{1}» al dispositivo «{2}»?", "MessageDownloadingEpisode": "Scaricamento dell’episodio in corso", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", "MessageEmbedFailed": "Incorporamento non riuscito!", "MessageEmbedFinished": "Incorporamento finito!", "MessageEpisodesQueuedForDownload": "{0} episodio(i) in coda per lo scaricamento", - "MessageEreaderDevices": "Per garantire la consegna degli ebook, potrebbe essere necessario aggiungere l'indirizzo e-mail sopra indicato come mittente valido per ciascun dispositivo elencato di seguito.", + "MessageEreaderDevices": "Per garantire la consegna dei libri digitali, potrebbe essere necessario aggiungere l'indirizzo e-mail sopra indicato come mittente valido per ciascun dispositivo elencato di seguito.", "MessageFeedURLWillBe": "l’URL del flusso sarà {0}", "MessageFetching": "Recupero info…", "MessageForceReScanDescription": "eseguirà nuovamente la scansione di tutti i file come una nuova scansione. I tag ID3 dei file audio, i file OPF e i file di testo verranno scansionati come nuovi.", @@ -805,8 +857,8 @@ "ToastRSSFeedCloseSuccess": "Flusso RSS chiuso", "ToastRemoveItemFromCollectionFailed": "Errore rimozione file dalla Raccolta", "ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta", - "ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo", - "ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"", + "ToastSendEbookToDeviceFailed": "Impossibile inviare il libro al dispositivo", + "ToastSendEbookToDeviceSuccess": "Libro inviato al dispositivo «{0}»", "ToastSeriesUpdateFailed": "Aggiornamento Serie Fallito", "ToastSeriesUpdateSuccess": "Serie Aggiornate", "ToastServerSettingsUpdateFailed": "Impossibile aggiornare le impostazioni del server", From 6a388cd4fe0c6583787e81c9489a805b6a6b5de8 Mon Sep 17 00:00:00 2001 From: biuklija Date: Wed, 11 Sep 2024 21:12:11 +0000 Subject: [PATCH 111/539] Translated using Weblate (Croatian) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 72058d7082..2bf0cf9eaf 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -98,6 +98,7 @@ "ButtonStats": "Statistika", "ButtonSubmit": "Podnesi", "ButtonTest": "Test", + "ButtonUnlinkOpenId": "Prekini vezu s OpenID-jem", "ButtonUpload": "Učitaj", "ButtonUploadBackup": "Učitaj sigurnosnu kopiju", "ButtonUploadCover": "Učitaj naslovnicu", From f9edadbafd419d879736860b4efc9e30ab1faddd Mon Sep 17 00:00:00 2001 From: gfbdrgng Date: Thu, 12 Sep 2024 13:01:10 +0000 Subject: [PATCH 112/539] Translated using Weblate (Russian) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/ru.json b/client/strings/ru.json index 374ad87976..bfefb5bd87 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -98,6 +98,7 @@ "ButtonStats": "Статистика", "ButtonSubmit": "Применить", "ButtonTest": "Тест", + "ButtonUnlinkOpenId": "Отключить OpenID", "ButtonUpload": "Загрузить", "ButtonUploadBackup": "Загрузить бэкап", "ButtonUploadCover": "Загрузить обложку", From 86aece6828a15359a340d97e6d7908147454fe8f Mon Sep 17 00:00:00 2001 From: burghy86 Date: Fri, 13 Sep 2024 19:47:51 +0000 Subject: [PATCH 113/539] Translated using Weblate (Italian) Currently translated at 92.8% (904 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/client/strings/it.json b/client/strings/it.json index 792cbfeae8..ac0bec4e77 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -378,14 +378,19 @@ "LabelLimit": "Limiti", "LabelLineSpacing": "Interlinea", "LabelListenAgain": "Ascolta ancora", + "LabelLogLevelDebug": "Debug", "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Allarme", "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", "LabelLowestPriority": "Priorità Minima", "LabelMatchExistingUsersBy": "Abbina gli utenti esistenti per", "LabelMatchExistingUsersByDescription": "Utilizzato per connettere gli utenti esistenti. Una volta connessi, gli utenti verranno abbinati a un ID univoco dal tuo provider SSO", + "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", + "LabelMetaTag": "Meta Tag", + "LabelMetaTags": "Meta Tags", "LabelMetadataOrderOfPrecedenceDescription": "Le origini di metadati con priorità più alta sovrascriveranno le origini di metadati con priorità inferiore", + "LabelMetadataProvider": "Metadata Provider", "LabelMinute": "Minuto", "LabelMinutes": "Minuti", "LabelMissing": "Altro", @@ -438,16 +443,19 @@ "LabelPersonalYearReview": "Il tuo anno in rassegna ({0})", "LabelPhotoPathURL": "foto Path/URL", "LabelPlayMethod": "Metodo di riproduzione", + "LabelPlayerChapterNumberMarker": "{0} di {1}", "LabelPlaylists": "Playlist", "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Area di ricerca podcast", "LabelPodcastType": "Tipo di Podcast", "LabelPodcasts": "Podcast", + "LabelPort": "Porta", "LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)", "LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google", "LabelPrimaryEbook": "Libro principale", "LabelProgress": "Cominciati", "LabelProvider": "Fornitore", + "LabelProviderAuthorizationValue": "Authorization Header Value", "LabelPubDate": "Data di pubblicazione", "LabelPublishYear": "Anno di pubblicazione", "LabelPublishedDate": "{0} pubblicati", @@ -657,8 +665,10 @@ "MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?", "MessageConfirmMarkAllEpisodesNotFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come non completati?", "MessageConfirmMarkItemFinished": "Sei sicuro/sicura di voler segnare {0} come finito?", + "MessageConfirmMarkItemNotFinished": "Vuoi davvero segnare \"{0}\" come non finito?", "MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?", "MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?", + "MessageConfirmNotificationTestTrigger": "Attivare questa notifica con dati di prova?", "MessageConfirmPurgeCache": "L'eliminazione della cache eliminerà l'intera directory dei /metadata/cache.

Sei sicuro di voler rimuovere la directory della cache?", "MessageConfirmPurgeItemsCache": "L'eliminazione della cache degli elementi eliminerà l'intera directory /metadata/cache/oggetti.
Sei sicuro?", "MessageConfirmQuickEmbed": "Attenzione! L'incorporamento rapido non eseguirà il backup dei file audio. Assicurati di avere un backup dei tuoi file audio.

Vuoi Continuare?", @@ -677,7 +687,9 @@ "MessageConfirmRenameTag": "Sei sicuro/sicura di voler rinominare l'etichetta \"{0}\" in \"{1}\" per tutti gli oggetti?", "MessageConfirmRenameTagMergeNote": "Nota: Questa etichetta esiste già e verrà unito nella vecchia.", "MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".", + "MessageConfirmResetProgress": "Vuoi davvero azzerare i tuoi progressi?", "MessageConfirmSendEbookToDevice": "Sei sicuro/sicura di voler inviare {0} libro «{1}» al dispositivo «{2}»?", + "MessageConfirmUnlinkOpenId": "Vuoi davvero scollegare questo utente da OpenID?", "MessageDownloadingEpisode": "Scaricamento dell’episodio in corso", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", "MessageEmbedFailed": "Incorporamento non riuscito!", @@ -712,6 +724,7 @@ "MessageNoCollections": "Nessuna Raccolta", "MessageNoCoversFound": "Nessuna Cover Trovata", "MessageNoDescription": "Nessuna descrizione", + "MessageNoDevices": "nessun dispositivo", "MessageNoDownloadsInProgress": "Nessun download attualmente in corso", "MessageNoDownloadsQueued": "Nessuna coda di download", "MessageNoEpisodeMatchesFound": "Nessun episodio corrispondente trovato", @@ -739,6 +752,7 @@ "MessagePauseChapter": "Metti in Pausa Capitolo", "MessagePlayChapter": "Ascolta dall'inizio del capitolo", "MessagePlaylistCreateFromCollection": "Crea playlist da una Raccolta", + "MessagePleaseWait": "Attendi...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast non ha l'URL del feed RSS da utilizzare per il match", "MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".", "MessageRemoveChapter": "Rimuovi Capitolo", @@ -799,18 +813,28 @@ "StatsYearInReview": "ANNO IN RASSEGNA", "ToastAccountUpdateFailed": "Aggiornamento Account Fallito", "ToastAccountUpdateSuccess": "Account Aggiornato", + "ToastAppriseUrlRequired": "È necessario immettere un indirizzo Apprise", "ToastAuthorImageRemoveSuccess": "Immagine Autore Rimossa", + "ToastAuthorNotFound": "Autore\"{0}\" non trovato", + "ToastAuthorRemoveSuccess": "Autore rimosso", + "ToastAuthorSearchNotFound": "Autore non trovato", "ToastAuthorUpdateFailed": "Aggiornamento Autore Fallito", "ToastAuthorUpdateMerged": "Autore unito", "ToastAuthorUpdateSuccess": "Autore aggiornato", "ToastAuthorUpdateSuccessNoImageFound": "Autore aggiornato (nessuna immagine trovata)", + "ToastBackupAppliedSuccess": "Backup applicato", "ToastBackupCreateFailed": "creazione backup fallita", "ToastBackupCreateSuccess": "Backup creato", "ToastBackupDeleteFailed": "Eliminazione backup fallita", "ToastBackupDeleteSuccess": "backup Eliminato", + "ToastBackupInvalidMaxKeep": "Numero non valido di backup da conservare", + "ToastBackupInvalidMaxSize": "Dimensione massima del backup non valida", + "ToastBackupPathUpdateFailed": "Impossibile aggiornare il percorso di backup", "ToastBackupRestoreFailed": "Ripristino fallito", "ToastBackupUploadFailed": "Caricamento backup fallito", "ToastBackupUploadSuccess": "Backup caricato", + "ToastBatchDeleteFailed": "Eliminazione batch non riuscita", + "ToastBatchDeleteSuccess": "Eliminazione batch riuscita", "ToastBatchUpdateFailed": "Batch di aggiornamento fallito", "ToastBatchUpdateSuccess": "Batch di aggiornamento finito", "ToastBookmarkCreateFailed": "Creazione segnalibro fallita", @@ -822,12 +846,20 @@ "ToastCachePurgeSuccess": "Cache eliminata correttamente", "ToastChaptersHaveErrors": "I capitoli contengono errori", "ToastChaptersMustHaveTitles": "I capitoli devono avere titoli", + "ToastChaptersRemoved": "Capitoli rimossi", + "ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito", + "ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito", "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta", "ToastCollectionRemoveSuccess": "Collezione rimossa", "ToastCollectionUpdateFailed": "Errore aggiornamento Raccolta", "ToastCollectionUpdateSuccess": "Raccolta aggiornata", + "ToastCoverUpdateFailed": "Aggiornamento cover fallito", "ToastDeleteFileFailed": "Impossibile eliminare il file", "ToastDeleteFileSuccess": "File eliminato", + "ToastDeviceAddFailed": "Aggiunta dispositivo fallita", + "ToastDeviceNameAlreadyExists": "Esiste già un dispositivo e-reader con quel nome", + "ToastDeviceTestEmailFailed": "Impossibile inviare l'e-mail di prova", + "ToastDeviceTestEmailSuccess": "Test invio mail completato", "ToastErrorCannotShare": "Impossibile condividere in modo nativo su questo dispositivo", "ToastFailedToLoadData": "Impossibile caricare i dati", "ToastItemCoverUpdateFailed": "Errore Aggiornamento cover", From 8cd8a157a6b4e55cd8fccbacd8dd5c25320e76ef Mon Sep 17 00:00:00 2001 From: biuklija Date: Sat, 14 Sep 2024 19:50:45 +0000 Subject: [PATCH 114/539] Translated using Weblate (Croatian) Currently translated at 100.0% (974 of 974 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 2bf0cf9eaf..0b6fc2285d 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -247,7 +247,7 @@ "LabelBooks": "knjiga/e", "LabelButtonText": "Tekst gumba", "LabelByAuthor": "po {0}", - "LabelChangePassword": "Promijeni lozinku", + "LabelChangePassword": "Promijeni zaporku", "LabelChannels": "Kanali", "LabelChapterTitle": "Naslov poglavlja", "LabelChapters": "Poglavlja", @@ -260,7 +260,7 @@ "LabelCollection": "Zbirka", "LabelCollections": "Zbirka/i", "LabelComplete": "Dovršeno", - "LabelConfirmPassword": "Potvrdi lozinku", + "LabelConfirmPassword": "Potvrda zaporke", "LabelContinueListening": "Nastavi slušati", "LabelContinueReading": "Nastavi čitati", "LabelContinueSeries": "Nastavi serijal", @@ -409,7 +409,7 @@ "LabelNarrator": "Pripovjedač", "LabelNarrators": "Pripovjedači", "LabelNew": "Novo", - "LabelNewPassword": "Nova lozinka", + "LabelNewPassword": "Nova zaporka", "LabelNewestAuthors": "Najnoviji autori", "LabelNewestEpisodes": "Najnoviji nastavci", "LabelNextBackupDate": "Sljedeće izrada sigurnosne kopije", From b35fabbe55146f7d2a721a4bbfea51cb3f5c0ad4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Sep 2024 16:04:50 -0500 Subject: [PATCH 115/539] Update:Collection & playlist Play button renamed to Play All #3320 --- client/pages/collection/_id.vue | 2 +- client/pages/playlist/_id.vue | 2 +- client/strings/en-us.json | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/pages/collection/_id.vue b/client/pages/collection/_id.vue index b130682dc3..ec40d7226a 100644 --- a/client/pages/collection/_id.vue +++ b/client/pages/collection/_id.vue @@ -16,7 +16,7 @@ play_arrow - {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} + {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlayAll }} diff --git a/client/pages/playlist/_id.vue b/client/pages/playlist/_id.vue index d36c9ea3c3..5cd31885fb 100644 --- a/client/pages/playlist/_id.vue +++ b/client/pages/playlist/_id.vue @@ -16,7 +16,7 @@ play_arrow - {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} + {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlayAll }} diff --git a/client/strings/en-us.json b/client/strings/en-us.json index b3c0dec6c6..9e1643e17f 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -56,6 +56,7 @@ "ButtonOpenManager": "Open Manager", "ButtonPause": "Pause", "ButtonPlay": "Play", + "ButtonPlayAll": "Play All", "ButtonPlaying": "Playing", "ButtonPlaylists": "Playlists", "ButtonPrevious": "Previous", From 999ada03d16fd5b6faf387c757c454e74dcb50a6 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 14:36:47 -0700 Subject: [PATCH 116/539] Fix: missing variables --- test/server/migrations/v2.13.5-series-column-unique.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index 19dc4a8e45..5ce5a465f0 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -16,11 +16,13 @@ describe('migration-v2.13.5-series-column-unique', () => { let series3Id let series1Id_dup let series3Id_dup + let series1Id_dup2 let book1Id let book2Id let book3Id let book4Id let book5Id + let book6Id let library1Id let library2Id let bookSeries1Id @@ -28,6 +30,7 @@ describe('migration-v2.13.5-series-column-unique', () => { let bookSeries3Id let bookSeries1Id_dup let bookSeries3Id_dup + let bookSeries1Id_dup2 beforeEach(() => { sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) @@ -164,7 +167,7 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(bookSeries).to.deep.include({ id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id }) expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id }) }) - it('update with same series name in different libraries', async () => { + it('upgrade with same series name in different libraries', async () => { // Add some entries to the Series table using the UUID for the ids await queryInterface.bulkInsert('Series', [ { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, From 836d772cd4502f109a06d8c78f824a03bad0d249 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 15:23:29 -0700 Subject: [PATCH 117/539] Update: remove the same book if occurs multiple times in duplicate series --- .../v2.13.5-series-column-unique.js | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js index e7201bae1c..21e4d4d5dd 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -20,7 +20,15 @@ async function up({ context: { queryInterface, logger } }) { // Upwards migration script logger.info('UPGRADE BEGIN: 2.13.5-series-column-unique ') - // Use the queryInterface to get the series table and find duplicates in the `name` column + // The steps taken to deduplicate the series are as follows: + // 1. Find all duplicate series in the `Series` table. + // 2. Iterate over the duplicate series and find all book IDs that are associated with the duplicate series in `bookSeries` table. + // 2.a For each book ID, check if the ID occurs multiple times for the duplicate series. + // 2.b If so, keep only one of the rows that has this bookId and seriesId. + // 3. Update `bookSeries` table to point to the most recent series. + // 4. Delete the older series. + + // Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column const [duplicates] = await queryInterface.sequelize.query(` SELECT name, libraryId, MAX(updatedAt) AS latestUpdatedAt, COUNT(name) AS count FROM Series @@ -36,6 +44,70 @@ async function up({ context: { queryInterface, logger } }) { // Report the series name that is being deleted logger.info(`[2.13.5 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) + // Determine any duplicate book IDs in the `bookSeries` table for the same series + const [duplicateBookIds] = await queryInterface.sequelize.query( + ` + SELECT bookId, COUNT(bookId) AS count + FROM BookSeries + WHERE seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ) + GROUP BY bookId + HAVING COUNT(bookId) > 1 + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId + } + } + ) + + // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId + for (const { bookId } of duplicateBookIds) { + // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last + const [duplicateBookSeries] = await queryInterface.sequelize.query( + ` + SELECT id + FROM BookSeries + WHERE bookId = :bookId + AND seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ) + ORDER BY sequence NULLS LAST + `, + { + replacements: { + bookId, + name: duplicate.name, + libraryId: duplicate.libraryId + } + } + ) + + // remove the first element from the array + duplicateBookSeries.shift() + + // Delete the remaining duplicate rows + if (duplicateBookSeries.length > 0) { + const [deletedBookSeries] = await queryInterface.sequelize.query( + ` + DELETE FROM BookSeries + WHERE id IN (:ids) + `, + { + replacements: { + ids: duplicateBookSeries.map((row) => row.id) + } + } + ) + } + } + // Get all the most recent series which matches the `name` and `libraryId` const [mostRecentSeries] = await queryInterface.sequelize.query( ` From 691ed88096082a42f30e077c0f3c3170c84ab5af Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 15:34:38 -0700 Subject: [PATCH 118/539] Add more logging, clean up typo --- .../v2.13.5-series-column-unique.js | 2 ++ .../v2.13.5-series-column-unique.test.js | 34 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js index 21e4d4d5dd..8c97758821 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -67,6 +67,7 @@ async function up({ context: { queryInterface, logger } }) { // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId for (const { bookId } of duplicateBookIds) { + logger.info(`[2.13.5 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last const [duplicateBookSeries] = await queryInterface.sequelize.query( ` @@ -106,6 +107,7 @@ async function up({ context: { queryInterface, logger } }) { } ) } + logger.info(`[2.13.5 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) } // Get all the most recent series which matches the `name` and `libraryId` diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index 5ce5a465f0..2fd59eeb4a 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -211,13 +211,15 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) - expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.callCount).to.equal(8) expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -240,13 +242,15 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) - expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.callCount).to.equal(8) expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -263,26 +267,28 @@ describe('migration-v2.13.5-series-column-unique', () => { ]) // Create a book that is in both series await queryInterface.bulkInsert('BookSeries', [ - { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, + { id: bookSeries1Id, sequence: '3', bookId: book1Id, seriesId: series1Id }, { id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series2Id } ]) await up({ context: { queryInterface, logger: Logger } }) - expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.callCount).to.equal(8) expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(bookSeries).to.have.length(1) - expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1, 2', bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '2', bookId: book1Id, seriesId: series1Id }) }) }) From 8b95dd65d917fe2c2b200918362b308f2514d6ee Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 15:43:10 -0700 Subject: [PATCH 119/539] Fix: test cases checking the wrong bookSeriesId --- .../migrations/v2.13.5-series-column-unique.test.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index 2fd59eeb4a..3c5b1b0407 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -226,7 +226,8 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(bookSeries).to.have.length(1) - expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: null, bookId: book1Id, seriesId: series1Id }) + // Keep BookSeries 2 because it was edited last from cleaning up duplicate books + expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book1Id, seriesId: series1Id }) }) it('upgrade with one book in two of the same series, one sequence is null', async () => { // Create two different series with the same name in the same library @@ -277,8 +278,8 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true @@ -288,7 +289,8 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(bookSeries).to.have.length(1) - expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '2', bookId: book1Id, seriesId: series1Id }) + // Keep BookSeries 2 because it is the lower sequence number + expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series1Id }) }) }) From 0b31792660590c063e41f3c0222912c9440a36e8 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 15 Sep 2024 11:48:33 +0300 Subject: [PATCH 120/539] catch file extraction errors in parseComicMetadata --- server/utils/parsers/parseComicMetadata.js | 94 ++++++++++++---------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/server/utils/parsers/parseComicMetadata.js b/server/utils/parsers/parseComicMetadata.js index 77e5ec2ac3..d2ba702da6 100644 --- a/server/utils/parsers/parseComicMetadata.js +++ b/server/utils/parsers/parseComicMetadata.js @@ -13,13 +13,13 @@ const parseComicInfoMetadata = require('./parseComicInfoMetadata') */ async function getComicFileBuffer(filepath) { if (!(await fs.pathExists(filepath))) { - Logger.error(`Comic path does not exist "${filepath}"`) + Logger.error(`[parseComicMetadata] Comic path does not exist "${filepath}"`) return null } try { return fs.readFile(filepath) } catch (error) { - Logger.error(`Failed to read comic at "${filepath}"`, error) + Logger.error(`[parseComicMetadata] Failed to read comic at "${filepath}"`, error) return null } } @@ -36,23 +36,25 @@ async function extractCoverImage(comicPath, comicImageFilepath, outputCoverPath) const comicFileBuffer = await getComicFileBuffer(comicPath) if (!comicFileBuffer) return null - const archive = await Archive.open(comicFileBuffer) - const fileEntry = await archive.extractSingleFile(comicImageFilepath) + let archive = null + try { + archive = await Archive.open(comicFileBuffer) + const fileEntry = await archive.extractSingleFile(comicImageFilepath) - if (!fileEntry?.fileData) { - Logger.error(`[parseComicMetadata] Invalid file entry data for comicPath "${comicPath}"/${comicImageFilepath}`) - return false - } + if (!fileEntry?.fileData) { + Logger.error(`[parseComicMetadata] Invalid file entry data for comicPath "${comicPath}"/${comicImageFilepath}`) + return false + } - try { await fs.writeFile(outputCoverPath, fileEntry.fileData) + return true } catch (error) { - Logger.error(`[parseComicMetadata] Failed to extract image from comicPath "${comicPath}"`, error) + Logger.error(`[parseComicMetadata] Failed to extract image "${comicImageFilepath}" from comicPath "${comicPath}" into "${outputCoverPath}"`, error) return false } finally { // Ensure we free the memory - archive.close() + archive?.close() } } module.exports.extractCoverImage = extractCoverImage @@ -70,46 +72,52 @@ async function parse(ebookFile) { const comicFileBuffer = await getComicFileBuffer(comicPath) if (!comicFileBuffer) return null - const archive = await Archive.open(comicFileBuffer) + let archive = null + try { + archive = await Archive.open(comicFileBuffer) - const fileObjects = await archive.getFilesArray() + const fileObjects = await archive.getFilesArray() - fileObjects.sort((a, b) => { - return a.file.name.localeCompare(b.file.name, undefined, { - numeric: true, - sensitivity: 'base' + fileObjects.sort((a, b) => { + return a.file.name.localeCompare(b.file.name, undefined, { + numeric: true, + sensitivity: 'base' + }) }) - }) - let metadata = null - const comicInfo = fileObjects.find((fo) => fo.file.name === 'ComicInfo.xml') - if (comicInfo) { - const comicInfoEntry = await comicInfo.file.extract() - if (comicInfoEntry?.fileData) { - const comicInfoStr = new TextDecoder().decode(comicInfoEntry.fileData) - const comicInfoJson = await xmlToJSON(comicInfoStr) - if (comicInfoJson) { - metadata = parseComicInfoMetadata.parse(comicInfoJson) + let metadata = null + const comicInfo = fileObjects.find((fo) => fo.file.name === 'ComicInfo.xml') + if (comicInfo) { + const comicInfoEntry = await comicInfo.file.extract() + if (comicInfoEntry?.fileData) { + const comicInfoStr = new TextDecoder().decode(comicInfoEntry.fileData) + const comicInfoJson = await xmlToJSON(comicInfoStr) + if (comicInfoJson) { + metadata = parseComicInfoMetadata.parse(comicInfoJson) + } } } - } - - const payload = { - path: comicPath, - ebookFormat: ebookFile.ebookFormat, - metadata - } - const firstImage = fileObjects.find((fo) => globals.SupportedImageTypes.includes(Path.extname(fo.file.name).toLowerCase().slice(1))) - if (firstImage?.file?._path) { - payload.ebookCoverPath = firstImage.file._path - } else { - Logger.warn(`Cover image not found in comic at "${comicPath}"`) - } + const payload = { + path: comicPath, + ebookFormat: ebookFile.ebookFormat, + metadata + } - // Ensure we close the archive to free memory - archive.close() + const firstImage = fileObjects.find((fo) => globals.SupportedImageTypes.includes(Path.extname(fo.file.name).toLowerCase().slice(1))) + if (firstImage?.file?._path) { + payload.ebookCoverPath = firstImage.file._path + } else { + Logger.warn(`[parseComicMetadata] Cover image not found in comic at "${comicPath}"`) + } - return payload + return payload + } catch (error) { + Logger.error(`[parseComicMetadata] Failed to parse comic metadata at "${comicPath}"`, error) + return null + } finally { + // Ensure we free the memory + archive?.close() + } } module.exports.parse = parse From fcacda74cba31e10aa66157d1a6c61aaf4b8151b Mon Sep 17 00:00:00 2001 From: wommy Date: Sun, 15 Sep 2024 18:29:23 -0400 Subject: [PATCH 121/539] added postcssOptions to remove npm warning --- client/nuxt.config.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/nuxt.config.js b/client/nuxt.config.js index 7dde55728e..0bca2a1438 100644 --- a/client/nuxt.config.js +++ b/client/nuxt.config.js @@ -129,10 +129,12 @@ module.exports = { // Build Configuration: https://go.nuxtjs.dev/config-build build: { postcss: { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, + postcssOptions: { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + } + } } }, watchers: { From 22ad16e11b427ff6301a781025cea96cf98b8ce3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 17 Sep 2024 16:10:32 -0500 Subject: [PATCH 122/539] Fix:Server crash on scan for library with no metadataPrecedence set #3434 --- server/models/Library.js | 6 +++++- server/scanner/BookScanner.js | 2 +- server/scanner/LibraryScanner.js | 7 ++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/server/models/Library.js b/server/models/Library.js index 972aa264fd..90dd2512ee 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -70,11 +70,15 @@ class Library extends Model { epubsAllowScriptedContent: false, hideSingleBookSeries: false, onlyShowLaterBooksInContinueSeries: false, - metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] + metadataPrecedence: this.defaultMetadataPrecedence } } } + static get defaultMetadataPrecedence() { + return ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] + } + /** * * @returns {Promise} diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 07f817a8ec..279fcf64da 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -655,7 +655,7 @@ class BookScanner { } const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) - const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] + const metadataPrecedence = librarySettings.metadataPrecedence || Database.libraryModel.defaultMetadataPrecedence libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`) for (const metadataSource of metadataPrecedence) { if (bookMetadataSourceHandler[metadataSource]) { diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 760224158f..5cd8b5c62d 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -59,9 +59,10 @@ class LibraryScanner { return } - if (library.isBook && library.settings.metadataPrecedence.join() !== library.lastScanMetadataPrecedence.join()) { + const metadataPrecedence = library.settings.metadataPrecedence || Database.libraryModel.defaultMetadataPrecedence + if (library.isBook && metadataPrecedence.join() !== library.lastScanMetadataPrecedence.join()) { const lastScanMetadataPrecedence = library.lastScanMetadataPrecedence?.join() || 'Unset' - Logger.info(`[LibraryScanner] Library metadata precedence changed since last scan. From [${lastScanMetadataPrecedence}] to [${library.settings.metadataPrecedence.join()}]`) + Logger.info(`[LibraryScanner] Library metadata precedence changed since last scan. From [${lastScanMetadataPrecedence}] to [${metadataPrecedence.join()}]`) forceRescan = true } @@ -90,7 +91,7 @@ class LibraryScanner { library.lastScanVersion = packageJson.version if (library.isBook) { const newExtraData = library.extraData || {} - newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence + newExtraData.lastScanMetadataPrecedence = metadataPrecedence library.extraData = newExtraData library.changed('extraData', true) } From 66b290577c3663a62edd2f5aaf8291b18cb72b98 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Tue, 17 Sep 2024 20:00:06 -0700 Subject: [PATCH 123/539] Clean up unused parts of statement --- server/migrations/v2.13.5-series-column-unique.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js index 8c97758821..1860772d2a 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -1,5 +1,3 @@ -const Logger = require('../Logger') - /** * @typedef MigrationContext * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. @@ -30,7 +28,7 @@ async function up({ context: { queryInterface, logger } }) { // Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column const [duplicates] = await queryInterface.sequelize.query(` - SELECT name, libraryId, MAX(updatedAt) AS latestUpdatedAt, COUNT(name) AS count + SELECT name, libraryId FROM Series GROUP BY name, libraryId HAVING COUNT(name) > 1 @@ -47,7 +45,7 @@ async function up({ context: { queryInterface, logger } }) { // Determine any duplicate book IDs in the `bookSeries` table for the same series const [duplicateBookIds] = await queryInterface.sequelize.query( ` - SELECT bookId, COUNT(bookId) AS count + SELECT bookId FROM BookSeries WHERE seriesId IN ( SELECT id From 0d08aecd56b13695c490c4797eb462b8f6dc794e Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 18 Sep 2024 08:28:15 +0300 Subject: [PATCH 124/539] Move from libarchive to node-unrar-js for cbr and node-stream-zip for cbz --- package-lock.json | 9 + package.json | 1 + server/utils/comicBookExtractors.js | 196 +++++++++++++++++++++ server/utils/parsers/parseComicMetadata.js | 68 ++----- 4 files changed, 225 insertions(+), 49 deletions(-) create mode 100644 server/utils/comicBookExtractors.js diff --git a/package-lock.json b/package-lock.json index 90493a065c..6f0a3587fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", + "node-unrar-js": "^2.0.2", "nodemailer": "^6.9.13", "openid-client": "^5.6.1", "p-throttle": "^4.1.1", @@ -3565,6 +3566,14 @@ "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "dev": true }, + "node_modules/node-unrar-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-unrar-js/-/node-unrar-js-2.0.2.tgz", + "integrity": "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/nodemailer": { "version": "6.9.13", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", diff --git a/package.json b/package.json index 70cf40c2d7..752b2f8df5 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", + "node-unrar-js": "^2.0.2", "nodemailer": "^6.9.13", "openid-client": "^5.6.1", "p-throttle": "^4.1.1", diff --git a/server/utils/comicBookExtractors.js b/server/utils/comicBookExtractors.js new file mode 100644 index 0000000000..3443a570df --- /dev/null +++ b/server/utils/comicBookExtractors.js @@ -0,0 +1,196 @@ +const Path = require('path') +const fs = require('../libs/fsExtra') +const os = require('os') +const Logger = require('../Logger') +const { isWritable } = require('./fileUtils') + +const StreamZip = require('../libs/nodeStreamZip') +const Archive = require('../libs/libarchive/archive') +const unrar = require('node-unrar-js') + +class AbstractComicBookExtractor { + constructor(comicPath) { + this.comicPath = comicPath + } + + async getBuffer() { + if (!(await fs.pathExists(this.comicPath))) { + Logger.error(`[parseComicMetadata] Comic path does not exist "${this.comicPath}"`) + return null + } + try { + return fs.readFile(this.comicPath) + } catch (error) { + Logger.error(`[parseComicMetadata] Failed to read comic at "${this.comicPath}"`, error) + return null + } + } + + async open() { + throw new Error('Not implemented') + } + + async getFilePaths() { + throw new Error('Not implemented') + } + + async extractToFile(filePath, outputFilePath) { + throw new Error('Not implemented') + } + + async extractToBuffer(filePath) { + throw new Error('Not implemented') + } + + close() { + throw new Error('Not implemented') + } +} + +class CbrComicBookExtractor extends AbstractComicBookExtractor { + constructor(comicPath) { + super(comicPath) + this.archive = null + this.tmpDir = null + } + + async open() { + this.tmpDir = global.MetadataPath ? Path.join(global.MetadataPath, 'tmp') : os.tmpdir() + await fs.ensureDir(this.tmpDir) + if (!(await isWritable(this.tmpDir))) throw new Error(`[CbrComicBookExtractor] Temp directory "${this.tmpDir}" is not writable`) + this.archive = await unrar.createExtractorFromFile({ filepath: this.comicPath, targetPath: this.tmpDir }) + Logger.debug(`[CbrComicBookExtractor] Opened comic book "${this.comicPath}". Using temp directory "${this.tmpDir}" for extraction.`) + } + + async getFilePaths() { + if (!this.archive) return null + const list = this.archive.getFileList() + const fileHeaders = [...list.fileHeaders] + const filePaths = fileHeaders.filter((fh) => !fh.flags.directory).map((fh) => fh.name) + Logger.debug(`[CbrComicBookExtractor] Found ${filePaths.length} files in comic book "${this.comicPath}"`) + return filePaths + } + + async extractToBuffer(file) { + if (!this.archive) return null + const extracted = this.archive.extract({ files: [file] }) + const files = [...extracted.files] + const filePath = Path.join(this.tmpDir, files[0].fileHeader.name) + const fileData = await fs.readFile(filePath) + await fs.remove(filePath) + Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${fileData.length}`) + return fileData + } + + async extractToFile(file, outputFilePath) { + if (!this.archive) return false + const extracted = this.archive.extract({ files: [file] }) + const files = [...extracted.files] + const fileEntry = files[0] + const extractedFilePath = Path.join(this.tmpDir, fileEntry.fileHeader.name) + await fs.move(extractedFilePath, outputFilePath, { overwrite: true }) + Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`) + return true + } + + close() { + Logger.debug(`[CbrComicBookExtractor] Closed comic book "${this.comicPath}"`) + } +} + +class CbzComicBookExtractor extends AbstractComicBookExtractor { + constructor(comicPath) { + super(comicPath) + this.archive = null + } + + async open() { + const buffer = await this.getBuffer() + this.archive = await Archive.open(buffer) + Logger.debug(`[CbzComicBookExtractor] Opened comic book "${this.comicPath}"`) + } + + async getFilePaths() { + if (!this.archive) return null + const list = await this.archive.getFilesArray() + const fileNames = list.map((fo) => fo.file._path) + Logger.debug(`[CbzComicBookExtractor] Found ${fileNames.length} files in comic book "${this.comicPath}"`) + return fileNames + } + + async extractToBuffer(file) { + if (!this.archive) return null + const extracted = await this.archive.extractSingleFile(file) + Logger.debug(`[CbzComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${extracted?.fileData.length}`) + return extracted?.fileData + } + + async extractToFile(file, outputFilePath) { + const data = await this.extractToBuffer(file) + if (!data) return false + await fs.writeFile(outputFilePath, data) + Logger.debug(`[CbzComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`) + return true + } + + close() { + this.archive?.close() + Logger.debug(`[CbzComicBookExtractor] Closed comic book "${this.comicPath}"`) + } +} + +class CbzStreamZipComicBookExtractor extends AbstractComicBookExtractor { + constructor(comicPath) { + super(comicPath) + this.archive = null + } + + async open() { + this.archive = new StreamZip.async({ file: this.comicPath }) + Logger.debug(`[CbzStreamZipComicBookExtractor] Opened comic book "${this.comicPath}"`) + } + + async getFilePaths() { + if (!this.archive) return null + const entries = await this.archive.entries() + const fileNames = Object.keys(entries).filter((entry) => !entries[entry].isDirectory) + Logger.debug(`[CbzStreamZipComicBookExtractor] Found ${fileNames.length} files in comic book "${this.comicPath}"`) + return fileNames + } + + async extractToBuffer(file) { + if (!this.archive) return null + const extracted = await this.archive?.entryData(file) + Logger.debug(`[CbzStreamZipComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${extracted.length}`) + return extracted + } + + async extractToFile(file, outputFilePath) { + if (!this.archive) return false + try { + await this.archive.extract(file, outputFilePath) + Logger.debug(`[CbzStreamZipComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`) + return true + } catch (error) { + Logger.error(`[CbzStreamZipComicBookExtractor] Failed to extract file "${file}" to "${outputFilePath}"`, error) + return false + } + } + + close() { + this.archive?.close() + Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book "${this.comicPath}"`) + } +} + +function createComicBookExtractor(comicPath) { + const ext = Path.extname(comicPath).toLowerCase() + if (ext === '.cbr') { + return new CbrComicBookExtractor(comicPath) + } else if (ext === '.cbz') { + return new CbzStreamZipComicBookExtractor(comicPath) + } else { + throw new Error(`Unsupported comic book format "${ext}"`) + } +} +module.exports = { createComicBookExtractor } diff --git a/server/utils/parsers/parseComicMetadata.js b/server/utils/parsers/parseComicMetadata.js index d2ba702da6..7ed0d1f50c 100644 --- a/server/utils/parsers/parseComicMetadata.js +++ b/server/utils/parsers/parseComicMetadata.js @@ -5,24 +5,7 @@ const Logger = require('../../Logger') const Archive = require('../../libs/libarchive/archive') const { xmlToJSON } = require('../index') const parseComicInfoMetadata = require('./parseComicInfoMetadata') - -/** - * - * @param {string} filepath - * @returns {Promise} - */ -async function getComicFileBuffer(filepath) { - if (!(await fs.pathExists(filepath))) { - Logger.error(`[parseComicMetadata] Comic path does not exist "${filepath}"`) - return null - } - try { - return fs.readFile(filepath) - } catch (error) { - Logger.error(`[parseComicMetadata] Failed to read comic at "${filepath}"`, error) - return null - } -} +const { createComicBookExtractor } = require('../comicBookExtractors.js') /** * Extract cover image from comic return true if success @@ -33,22 +16,11 @@ async function getComicFileBuffer(filepath) { * @returns {Promise} */ async function extractCoverImage(comicPath, comicImageFilepath, outputCoverPath) { - const comicFileBuffer = await getComicFileBuffer(comicPath) - if (!comicFileBuffer) return null - let archive = null try { - archive = await Archive.open(comicFileBuffer) - const fileEntry = await archive.extractSingleFile(comicImageFilepath) - - if (!fileEntry?.fileData) { - Logger.error(`[parseComicMetadata] Invalid file entry data for comicPath "${comicPath}"/${comicImageFilepath}`) - return false - } - - await fs.writeFile(outputCoverPath, fileEntry.fileData) - - return true + archive = createComicBookExtractor(comicPath) + await archive.open() + return await archive.extractToFile(comicImageFilepath, outputCoverPath) } catch (error) { Logger.error(`[parseComicMetadata] Failed to extract image "${comicImageFilepath}" from comicPath "${comicPath}" into "${outputCoverPath}"`, error) return false @@ -67,30 +39,28 @@ module.exports.extractCoverImage = extractCoverImage */ async function parse(ebookFile) { const comicPath = ebookFile.metadata.path - Logger.debug(`Parsing metadata from comic at "${comicPath}"`) - - const comicFileBuffer = await getComicFileBuffer(comicPath) - if (!comicFileBuffer) return null - + Logger.debug(`[parseComicMetadata] Parsing comic metadata at "${comicPath}"`) let archive = null try { - archive = await Archive.open(comicFileBuffer) + archive = createComicBookExtractor(comicPath) + await archive.open() - const fileObjects = await archive.getFilesArray() + const filePaths = await archive.getFilePaths() - fileObjects.sort((a, b) => { - return a.file.name.localeCompare(b.file.name, undefined, { + // Sort the file paths in a natural order to get the first image + filePaths.sort((a, b) => { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) }) let metadata = null - const comicInfo = fileObjects.find((fo) => fo.file.name === 'ComicInfo.xml') - if (comicInfo) { - const comicInfoEntry = await comicInfo.file.extract() - if (comicInfoEntry?.fileData) { - const comicInfoStr = new TextDecoder().decode(comicInfoEntry.fileData) + const comicInfoPath = filePaths.find((filePath) => filePath === 'ComicInfo.xml') + if (comicInfoPath) { + const comicInfoData = await archive.extractToBuffer(comicInfoPath) + if (comicInfoData) { + const comicInfoStr = new TextDecoder().decode(comicInfoData) const comicInfoJson = await xmlToJSON(comicInfoStr) if (comicInfoJson) { metadata = parseComicInfoMetadata.parse(comicInfoJson) @@ -104,9 +74,9 @@ async function parse(ebookFile) { metadata } - const firstImage = fileObjects.find((fo) => globals.SupportedImageTypes.includes(Path.extname(fo.file.name).toLowerCase().slice(1))) - if (firstImage?.file?._path) { - payload.ebookCoverPath = firstImage.file._path + const firstImagePath = filePaths.find((filePath) => globals.SupportedImageTypes.includes(Path.extname(filePath).toLowerCase().slice(1))) + if (firstImagePath) { + payload.ebookCoverPath = firstImagePath } else { Logger.warn(`[parseComicMetadata] Cover image not found in comic at "${comicPath}"`) } From 072028c740dcb66c500580c0134c4f9592d448cc Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 18 Sep 2024 10:16:46 +0300 Subject: [PATCH 125/539] Cleanup empty directiories inside the temp extraction dir --- server/utils/comicBookExtractors.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/server/utils/comicBookExtractors.js b/server/utils/comicBookExtractors.js index 3443a570df..be60d0ff91 100644 --- a/server/utils/comicBookExtractors.js +++ b/server/utils/comicBookExtractors.js @@ -71,6 +71,17 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor { return filePaths } + async removeEmptyParentDirs(file) { + let dir = Path.dirname(file) + while (dir !== '.') { + const fullDirPath = Path.join(this.tmpDir, dir) + const files = await fs.readdir(fullDirPath) + if (files.length > 0) break + await fs.remove(fullDirPath) + dir = Path.dirname(dir) + } + } + async extractToBuffer(file) { if (!this.archive) return null const extracted = this.archive.extract({ files: [file] }) @@ -78,6 +89,7 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor { const filePath = Path.join(this.tmpDir, files[0].fileHeader.name) const fileData = await fs.readFile(filePath) await fs.remove(filePath) + await this.removeEmptyParentDirs(files[0].fileHeader.name) Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${fileData.length}`) return fileData } @@ -86,9 +98,9 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor { if (!this.archive) return false const extracted = this.archive.extract({ files: [file] }) const files = [...extracted.files] - const fileEntry = files[0] - const extractedFilePath = Path.join(this.tmpDir, fileEntry.fileHeader.name) + const extractedFilePath = Path.join(this.tmpDir, files[0].fileHeader.name) await fs.move(extractedFilePath, outputFilePath, { overwrite: true }) + await this.removeEmptyParentDirs(files[0].fileHeader.name) Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`) return true } From 942bd0859fff6c185430dc7811d81945e1f3e0e5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 18 Sep 2024 18:01:36 +0300 Subject: [PATCH 126/539] Change PlaybackSession createFromOld to use upsert instead of create --- server/models/PlaybackSession.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/PlaybackSession.js b/server/models/PlaybackSession.js index 5442387f63..c7c6323af6 100644 --- a/server/models/PlaybackSession.js +++ b/server/models/PlaybackSession.js @@ -117,7 +117,7 @@ class PlaybackSession extends Model { static createFromOld(oldPlaybackSession) { const playbackSession = this.getFromOld(oldPlaybackSession) - return this.create(playbackSession, { + return this.upsert(playbackSession, { silent: true }) } From d796849d7471b7af6c9a665dd89137051468a69e Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 18 Sep 2024 18:44:16 +0300 Subject: [PATCH 127/539] Small change to logging of unhandled rejections --- server/Server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 8bbfa4d34a..17466e863c 100644 --- a/server/Server.js +++ b/server/Server.js @@ -182,7 +182,7 @@ class Server { * @see https://nodejs.org/api/process.html#event-unhandledrejection */ process.on('unhandledRejection', async (reason, promise) => { - await Logger.fatal(`[Server] Unhandled rejection: ${reason}, promise:`, util.format('%O', promise)) + await Logger.fatal('[Server] Unhandled rejection:', reason, '\npromise:', util.format('%O', promise)) process.exit(1) }) } From 1a8811b69afa267904746130da2a83b2f99709c2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 18 Sep 2024 14:26:10 -0500 Subject: [PATCH 128/539] Remove unused requires --- server/utils/comicBookExtractors.js | 7 +++---- server/utils/parsers/parseComicMetadata.js | 6 ++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/server/utils/comicBookExtractors.js b/server/utils/comicBookExtractors.js index be60d0ff91..9c18ebddc2 100644 --- a/server/utils/comicBookExtractors.js +++ b/server/utils/comicBookExtractors.js @@ -1,12 +1,11 @@ const Path = require('path') -const fs = require('../libs/fsExtra') const os = require('os') +const unrar = require('node-unrar-js') const Logger = require('../Logger') -const { isWritable } = require('./fileUtils') - +const fs = require('../libs/fsExtra') const StreamZip = require('../libs/nodeStreamZip') const Archive = require('../libs/libarchive/archive') -const unrar = require('node-unrar-js') +const { isWritable } = require('./fileUtils') class AbstractComicBookExtractor { constructor(comicPath) { diff --git a/server/utils/parsers/parseComicMetadata.js b/server/utils/parsers/parseComicMetadata.js index 7ed0d1f50c..38a41b51d8 100644 --- a/server/utils/parsers/parseComicMetadata.js +++ b/server/utils/parsers/parseComicMetadata.js @@ -1,10 +1,8 @@ const Path = require('path') -const globals = require('../globals') -const fs = require('../../libs/fsExtra') const Logger = require('../../Logger') -const Archive = require('../../libs/libarchive/archive') -const { xmlToJSON } = require('../index') const parseComicInfoMetadata = require('./parseComicInfoMetadata') +const globals = require('../globals') +const { xmlToJSON } = require('../index') const { createComicBookExtractor } = require('../comicBookExtractors.js') /** From 12bce48ef5f043b34c632f44ae10fc68c2b1ab15 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 18 Sep 2024 15:30:24 -0500 Subject: [PATCH 129/539] Update:Home page refetch items when scanning in first items --- client/components/app/BookShelfCategorized.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index d7d850d5c1..a977dd213d 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -347,6 +347,13 @@ export default { libraryItemsAdded(libraryItems) { console.log('libraryItems added', libraryItems) + // First items added to library + const isThisLibrary = libraryItems.some((li) => li.libraryId === this.currentLibraryId) + if (!this.shelves.length && !this.search && isThisLibrary) { + this.fetchCategories() + return + } + const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added') if (!recentlyAddedShelf) return From bb481ccfb421ab31a737bdd4aa819d55ee2f4331 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 19 Sep 2024 17:21:41 -0500 Subject: [PATCH 130/539] Update:Chapters page populate ASIN input in lookup modal after match #3428 --- client/pages/audiobook/_id/chapters.vue | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index 9dabb59d5c..5da4813d61 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -628,15 +628,27 @@ export default { .finally(() => { this.saving = false }) + }, + libraryItemUpdated(libraryItem) { + if (libraryItem.id === this.libraryItem.id) { + if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) { + this.asinInput = libraryItem.media.metadata.asin + } + this.libraryItem = libraryItem + } } }, mounted() { this.regionInput = localStorage.getItem('audibleRegion') || 'US' this.asinInput = this.mediaMetadata.asin || null this.initChapters() + + this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) }, beforeDestroy() { this.destroyAudioEl() + + this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) } } From 8512d5e693f5fdde3c9aad0d074526301498d7da Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 20 Sep 2024 17:18:29 -0500 Subject: [PATCH 131/539] Update Task object to handle translation keys with subs --- server/Watcher.js | 7 ++- server/managers/AbMergeManager.js | 49 +++++++++++++--- server/managers/AudioMetadataManager.js | 25 +++++--- server/managers/PodcastManager.js | 72 ++++++++++++++++++++--- server/managers/TaskManager.js | 27 +++++---- server/objects/Task.js | 76 ++++++++++++++++++++----- server/scanner/LibraryScanner.js | 9 ++- server/scanner/Scanner.js | 9 ++- 8 files changed, 220 insertions(+), 54 deletions(-) diff --git a/server/Watcher.js b/server/Watcher.js index 83c45234c9..0a5867bd65 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -301,7 +301,12 @@ class FolderWatcher extends EventEmitter { libraryId, libraryName: libwatcher.name } - this.pendingTask = TaskManager.createAndAddTask('watcher-scan', `Scanning file changes in "${libwatcher.name}"`, null, true, taskData) + const taskTitleString = { + text: `Scanning file changes in "${libwatcher.name}"`, + key: 'MessageTaskScanningFileChanges', + subs: [libwatcher.name] + } + this.pendingTask = TaskManager.createAndAddTask('watcher-scan', taskTitleString, null, true, taskData) } this.pendingFileUpdates.push({ path, diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index d94e948987..1fed95a18f 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -40,7 +40,11 @@ class AbMergeManager { * @returns {Promise} */ cancelEncode(task) { - task.setFailed('Task canceled by user') + const taskFailedString = { + text: 'Task canceled by user', + key: 'MessageTaskCanceledByUser' + } + task.setFailed(taskFailedString) return this.removeTask(task, true) } @@ -76,8 +80,17 @@ class AbMergeManager { duration: libraryItem.media.duration, encodeOptions: options } - const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.` - task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData) + + const taskTitleString = { + text: 'Encoding M4b', + key: 'MessageTaskEncodingM4b' + } + const taskDescriptionString = { + text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`, + key: 'MessageTaskEncodingM4bDescription', + subs: [libraryItem.media.metadata.title] + } + task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData) TaskManager.addTask(task) Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`) @@ -98,7 +111,11 @@ class AbMergeManager { // Make sure the target directory is writable if (!(await isWritable(task.data.libraryItemDir))) { Logger.error(`[AbMergeManager] Target directory is not writable: ${task.data.libraryItemDir}`) - task.setFailed('Target directory is not writable') + const taskFailedString = { + text: 'Target directory is not writable', + key: 'MessageTaskTargetDirectoryNotWritable' + } + task.setFailed(taskFailedString) this.removeTask(task, true) return } @@ -106,7 +123,11 @@ class AbMergeManager { // Create ffmetadata file if (!(await ffmpegHelpers.writeFFMetadataFile(task.data.ffmetadataObject, task.data.chapters, task.data.ffmetadataPath))) { Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`) - task.setFailed('Failed to write metadata file.') + const taskFailedString = { + text: 'Failed to write metadata file', + key: 'MessageTaskFailedToWriteMetadataFile' + } + task.setFailed(taskFailedString) this.removeTask(task, true) return } @@ -137,7 +158,11 @@ class AbMergeManager { Logger.info(`[AbMergeManager] Task cancelled ${task.id}`) } else { Logger.error(`[AbMergeManager] mergeAudioFiles failed`, error) - task.setFailed('Failed to merge audio files') + const taskFailedString = { + text: 'Failed to merge audio files', + key: 'MessageTaskFailedToMergeAudioFiles' + } + task.setFailed(taskFailedString) this.removeTask(task, true) } return @@ -164,7 +189,11 @@ class AbMergeManager { Logger.info(`[AbMergeManager] Task cancelled ${task.id}`) } else { Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`) - task.setFailed('Failed to write metadata to m4b file') + const taskFailedString = { + text: 'Failed to write metadata to m4b file', + key: 'MessageTaskFailedToWriteMetadataToM4bFile' + } + task.setFailed(taskFailedString) this.removeTask(task, true) } return @@ -196,7 +225,11 @@ class AbMergeManager { await fs.remove(task.data.tempFilepath) } catch (err) { Logger.error(`[AbMergeManager] Failed to move m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`, err) - task.setFailed('Failed to move m4b file') + const taskFailedString = { + text: 'Failed to move m4b file', + key: 'MessageTaskFailedToMoveM4bFile' + } + task.setFailed(taskFailedString) this.removeTask(task, true) return } diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 2dcbb1d445..8cd8039c8a 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -97,8 +97,17 @@ class AudioMetadataMangaer { }, duration: libraryItem.media.duration } - const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".` - task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData) + + const taskTitleString = { + text: 'Embedding Metadata', + key: 'MessageTaskEmbeddingMetadata' + } + const taskDescriptionString = { + text: `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`, + key: 'MessageTaskEmbeddingMetadataDescription', + subs: [libraryItem.media.metadata.title] + } + task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData) if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) { Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`) @@ -123,7 +132,7 @@ class AudioMetadataMangaer { Logger.debug(`[AudioMetadataManager] Target directory ${task.data.libraryItemDir} writable: ${targetDirWritable}`) if (!targetDirWritable) { Logger.error(`[AudioMetadataManager] Target directory is not writable: ${task.data.libraryItemDir}`) - task.setFailed('Target directory is not writable') + task.setFailedText('Target directory is not writable') this.handleTaskFinished(task) return } @@ -134,7 +143,7 @@ class AudioMetadataMangaer { await fs.access(af.path, fs.constants.W_OK) } catch (err) { Logger.error(`[AudioMetadataManager] Audio file is not writable: ${af.path}`) - task.setFailed(`Audio file "${Path.basename(af.path)}" is not writable`) + task.setFailedText(`Audio file "${Path.basename(af.path)}" is not writable`) this.handleTaskFinished(task) return } @@ -148,7 +157,7 @@ class AudioMetadataMangaer { cacheDirCreated = true } catch (err) { Logger.error(`[AudioMetadataManager] Failed to create cache directory ${task.data.itemCachePath}`, err) - task.setFailed('Failed to create cache directory') + task.setFailedText('Failed to create cache directory') this.handleTaskFinished(task) return } @@ -159,7 +168,7 @@ class AudioMetadataMangaer { const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath) if (!success) { Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`) - task.setFailed('Failed to write metadata file.') + task.setFailedText('Failed to write metadata file.') this.handleTaskFinished(task) return } @@ -181,7 +190,7 @@ class AudioMetadataMangaer { Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`) } catch (err) { Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err) - task.setFailed(`Failed to backup audio file "${Path.basename(af.path)}"`) + task.setFailedText(`Failed to backup audio file "${Path.basename(af.path)}"`) this.handleTaskFinished(task) return } @@ -195,7 +204,7 @@ class AudioMetadataMangaer { Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`) } catch (err) { Logger.error(`[AudioMetadataManager] Failed to tag audio file "${af.path}"`, err) - task.setFailed(`Failed to tag audio file "${Path.basename(af.path)}"`) + task.setFailedText(`Failed to tag audio file "${Path.basename(af.path)}"`) this.handleTaskFinished(task) return } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index adec59871c..9e0bdbc2dc 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -71,12 +71,20 @@ class PodcastManager { return } - const taskDescription = `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".` const taskData = { libraryId: podcastEpisodeDownload.libraryId, libraryItemId: podcastEpisodeDownload.libraryItemId } - const task = TaskManager.createAndAddTask('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData) + const taskTitleString = { + text: 'Downloading episode', + key: 'MessageDownloadingEpisode' + } + const taskDescriptionString = { + text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`, + key: 'MessageTaskDownloadingEpisodeDescription', + subs: [podcastEpisodeDownload.podcastEpisode.title] + } + const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) this.currentDownload = podcastEpisodeDownload @@ -119,14 +127,14 @@ class PodcastManager { if (!success) { await fs.remove(this.currentDownload.targetPath) this.currentDownload.setFinished(false) - task.setFailed('Failed to download episode') + task.setFailedText('Failed to download episode') } else { Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`) this.currentDownload.setFinished(true) task.setFinished() } } else { - task.setFailed('Failed to download episode') + task.setFailedText('Failed to download episode') this.currentDownload.setFinished(false) } @@ -407,13 +415,35 @@ class PodcastManager { * @param {import('../managers/CronManager')} cronManager */ async createPodcastsFromFeedUrls(rssFeedUrls, folder, autoDownloadEpisodes, cronManager) { - const task = TaskManager.createAndAddTask('opml-import', 'OPML import', `Creating podcasts from ${rssFeedUrls.length} RSS feeds`, true, null) + const taskTitleString = { + text: 'OPML import', + key: 'MessageTaskOpmlImport' + } + const taskDescriptionString = { + text: `Creating podcasts from ${rssFeedUrls.length} RSS feeds`, + key: 'MessageTaskOpmlImportDescription', + subs: [rssFeedUrls.length] + } + const task = TaskManager.createAndAddTask('opml-import', taskTitleString, taskDescriptionString, true, null) let numPodcastsAdded = 0 Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Importing ${rssFeedUrls.length} RSS feeds to folder "${folder.path}"`) for (const feedUrl of rssFeedUrls) { const feed = await getPodcastFeed(feedUrl).catch(() => null) if (!feed?.episodes) { - TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Importing RSS feed "${feedUrl}"`, 'Failed to get podcast feed') + const taskTitleStringFeed = { + text: 'OPML import feed', + key: 'MessageTaskOpmlImportFeed' + } + const taskDescriptionStringFeed = { + text: `Importing RSS feed "${feedUrl}"`, + key: 'MessageTaskOpmlImportFeedDescription', + subs: [feedUrl] + } + const taskErrorString = { + text: 'Failed to get podcast feed', + key: 'MessageTaskOpmlImportFeedFailed' + } + TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringFeed, taskErrorString) Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to get podcast feed for "${feedUrl}"`) continue } @@ -429,7 +459,20 @@ class PodcastManager { })) > 0 if (existingLibraryItem) { Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Podcast already exists at path "${podcastPath}"`) - TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Creating podcast "${feed.metadata.title}"`, 'Podcast already exists at path') + const taskTitleStringFeed = { + text: 'OPML import feed', + key: 'MessageTaskOpmlImportFeed' + } + const taskDescriptionStringPodcast = { + text: `Creating podcast "${feed.metadata.title}"`, + key: 'MessageTaskOpmlImportFeedPodcastDescription', + subs: [feed.metadata.title] + } + const taskErrorString = { + text: 'Podcast already exists at path', + key: 'MessageTaskOpmlImportFeedPodcastExists' + } + TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString) continue } @@ -442,7 +485,20 @@ class PodcastManager { }) if (!successCreatingPath) { Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast folder at "${podcastPath}"`) - TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Creating podcast "${feed.metadata.title}"`, 'Failed to create podcast folder') + const taskTitleStringFeed = { + text: 'OPML import feed', + key: 'MessageTaskOpmlImportFeed' + } + const taskDescriptionStringPodcast = { + text: `Creating podcast "${feed.metadata.title}"`, + key: 'MessageTaskOpmlImportFeedPodcastDescription', + subs: [feed.metadata.title] + } + const taskErrorString = { + text: 'Failed to create podcast folder', + key: 'MessageTaskOpmlImportFeedPodcastFailed' + } + TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString) continue } diff --git a/server/managers/TaskManager.js b/server/managers/TaskManager.js index 1a8b6c85b0..52c093a92b 100644 --- a/server/managers/TaskManager.js +++ b/server/managers/TaskManager.js @@ -1,6 +1,13 @@ const SocketAuthority = require('../SocketAuthority') const Task = require('../objects/Task') +/** + * @typedef TaskString + * @property {string} text + * @property {string} key + * @property {string[]} [subs] + */ + class TaskManager { constructor() { /** @type {Task[]} */ @@ -33,14 +40,14 @@ class TaskManager { * Create new task and add * * @param {string} action - * @param {string} title - * @param {string} description + * @param {TaskString} titleString + * @param {TaskString|null} descriptionString * @param {boolean} showSuccess * @param {Object} [data] */ - createAndAddTask(action, title, description, showSuccess, data = {}) { + createAndAddTask(action, titleString, descriptionString, showSuccess, data = {}) { const task = new Task() - task.setData(action, title, description, showSuccess, data) + task.setData(action, titleString, descriptionString, showSuccess, data) this.addTask(task) return task } @@ -49,14 +56,14 @@ class TaskManager { * Create new failed task and add * * @param {string} action - * @param {string} title - * @param {string} description - * @param {string} errorMessage + * @param {TaskString} titleString + * @param {TaskString|null} descriptionString + * @param {TaskString} errorMessageString */ - createAndEmitFailedTask(action, title, description, errorMessage) { + createAndEmitFailedTask(action, titleString, descriptionString, errorMessageString) { const task = new Task() - task.setData(action, title, description, false) - task.setFailed(errorMessage) + task.setData(action, titleString, descriptionString, false) + task.setFailedText(errorMessageString) SocketAuthority.emitter('task_started', task.toJSON()) return task } diff --git a/server/objects/Task.js b/server/objects/Task.js index db7e490e66..0409cad62c 100644 --- a/server/objects/Task.js +++ b/server/objects/Task.js @@ -1,4 +1,11 @@ -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 + +/** + * @typedef TaskString + * @property {string} text + * @property {string} key + * @property {string[]} [subs] + */ class Task { constructor() { @@ -11,10 +18,25 @@ class Task { /** @type {string} */ this.title = null + /** @type {string} - Used for translation */ + this.titleKey = null + /** @type {string[]} - Used for translation */ + this.titleSubs = null + /** @type {string} */ this.description = null + /** @type {string} - Used for translation */ + this.descriptionKey = null + /** @type {string[]} - Used for translation */ + this.descriptionSubs = null + /** @type {string} */ this.error = null + /** @type {string} - Used for translation */ + this.errorKey = null + /** @type {string[]} - Used for translation */ + this.errorSubs = null + /** @type {boolean} client should keep the task visible after success */ this.showSuccess = false @@ -47,30 +69,51 @@ class Task { /** * Set initial task data - * - * @param {string} action - * @param {string} title - * @param {string} description - * @param {boolean} showSuccess - * @param {Object} [data] + * + * @param {string} action + * @param {TaskString} titleString + * @param {TaskString|null} descriptionString + * @param {boolean} showSuccess + * @param {Object} [data] */ - setData(action, title, description, showSuccess, data = {}) { + setData(action, titleString, descriptionString, showSuccess, data = {}) { this.id = uuidv4() this.action = action this.data = { ...data } - this.title = title - this.description = description + this.title = titleString.text + this.titleKey = titleString.key || null + this.titleSubs = titleString.subs || null + this.description = descriptionString?.text || null + this.descriptionKey = descriptionString?.key || null + this.descriptionSubs = descriptionString?.subs || null this.showSuccess = showSuccess this.startedAt = Date.now() } /** * Set task as failed - * - * @param {string} message error message + * + * @param {TaskString} messageString + */ + setFailed(messageString) { + this.error = messageString.text + this.errorKey = messageString.key || null + this.errorSubs = messageString.subs || null + this.isFailed = true + this.failedAt = Date.now() + this.setFinished() + } + + /** + * Set task as failed without translation key + * TODO: Remove this method after all tasks are using translation keys + * + * @param {string} message */ - setFailed(message) { + setFailedText(message) { this.error = message + this.errorKey = null + this.errorSubs = null this.isFailed = true this.failedAt = Date.now() this.setFinished() @@ -78,15 +121,18 @@ class Task { /** * Set task as finished - * + * TODO: Update to use translation keys + * * @param {string} [newDescription] update description */ setFinished(newDescription = null) { if (newDescription) { this.description = newDescription + this.descriptionKey = null + this.descriptionSubs = null } this.isFinished = true this.finishedAt = Date.now() } } -module.exports = Task \ No newline at end of file +module.exports = Task diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 5cd8b5c62d..6b9f7893e1 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -76,7 +76,12 @@ class LibraryScanner { libraryName: library.name, libraryMediaType: library.mediaType } - const task = TaskManager.createAndAddTask('library-scan', `Scanning "${library.name}" library`, null, true, taskData) + const taskTitleString = { + text: `Scanning "${library.name}" library`, + key: 'MessageTaskScanningLibrary', + subs: [library.name] + } + const task = TaskManager.createAndAddTask('library-scan', taskTitleString, null, true, taskData) Logger.info(`[LibraryScanner] Starting${forceRescan ? ' (forced)' : ''} library scan ${libraryScan.id} for ${libraryScan.libraryName}`) @@ -104,7 +109,7 @@ class LibraryScanner { Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}.`, err) - task.setFailed(`Failed. ${libraryScan.scanResultsString}`) + task.setFailedText(`Failed. ${libraryScan.scanResultsString}`) } if (this.cancelLibraryScan[libraryScan.libraryId]) delete this.cancelLibraryScan[libraryScan.libraryId] diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 06657de228..6bb62706d7 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -368,7 +368,12 @@ class Scanner { const taskData = { libraryId: library.id } - const task = TaskManager.createAndAddTask('library-match-all', `Matching books in "${library.name}"`, null, true, taskData) + const taskTitleString = { + text: `Matching books in "${library.name}"`, + key: 'MessageTaskMatchingBooksInLibrary', + subs: [library.name] + } + const task = TaskManager.createAndAddTask('library-match-all', taskTitleString, null, true, taskData) Logger.info(`[Scanner] matchLibraryItems: Starting library match scan ${libraryScan.id} for ${libraryScan.libraryName}`) let hasMoreChunks = true @@ -393,7 +398,7 @@ class Scanner { if (offset === 0) { Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`) libraryScan.setComplete('Library has no items') - task.setFailed(libraryScan.error) + task.setFailedText(libraryScan.error) } else { libraryScan.setComplete() task.setFinished(isCanceled ? 'Canceled' : libraryScan.scanResultsString) From 1dec8ae12289638ebbc3d9c763071f7804b92935 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 21 Sep 2024 14:02:57 -0500 Subject: [PATCH 132/539] Update:Added string localization for tasks #3303 #3352 --- .../components/cards/ItemTaskRunningCard.vue | 39 +++++++++++++++-- client/plugins/i18n.js | 4 +- client/plugins/utils.js | 5 ++- client/strings/en-us.json | 32 ++++++++++++++ server/Watcher.js | 6 ++- server/managers/AbMergeManager.js | 7 +-- server/managers/AudioMetadataManager.js | 43 ++++++++++++++++--- server/managers/PodcastManager.js | 19 ++++++-- server/managers/TaskManager.js | 2 +- server/objects/Task.js | 35 +++++++-------- server/scanner/LibraryScan.js | 40 ++++++----------- server/scanner/LibraryScanner.js | 40 ++++++++++++----- server/scanner/Scanner.js | 24 ++++++++--- 13 files changed, 213 insertions(+), 83 deletions(-) diff --git a/client/components/cards/ItemTaskRunningCard.vue b/client/components/cards/ItemTaskRunningCard.vue index 14972df98d..12d1b61839 100644 --- a/client/components/cards/ItemTaskRunningCard.vue +++ b/client/components/cards/ItemTaskRunningCard.vue @@ -8,6 +8,7 @@

{{ title }}

{{ description }}

+

{{ specialMessage }}

{{ failedMessage }}

Canceling...

@@ -26,7 +27,16 @@ export default { }, data() { return { - cancelingScan: false + cancelingScan: false, + specialMessage: '' + } + }, + watch: { + task: { + immediate: true, + handler() { + this.initTask() + } } }, computed: { @@ -34,14 +44,17 @@ export default { return this.$store.getters['user/getIsAdminOrUp'] }, title() { + if (this.task.titleKey && this.$strings[this.task.titleKey]) { + return this.$getString(this.task.titleKey, this.task.titleSubs) + } return this.task.title || 'No Title' }, description() { + if (this.task.descriptionKey && this.$strings[this.task.descriptionKey]) { + return this.$getString(this.task.descriptionKey, this.task.descriptionSubs) + } return this.task.description || '' }, - details() { - return this.task.details || 'Unknown' - }, isFinished() { return !!this.task.isFinished }, @@ -52,6 +65,9 @@ export default { return this.isFinished && !this.isFailed }, failedMessage() { + if (this.task.errorKey && this.$strings[this.task.errorKey]) { + return this.$getString(this.task.errorKey, this.task.errorSubs) + } return this.task.error || '' }, action() { @@ -87,6 +103,21 @@ export default { } }, methods: { + initTask() { + // special message for library scan tasks + if (this.task?.data?.scanResults) { + const scanResults = this.task.data.scanResults + const strs = [] + if (scanResults.added) strs.push(this.$getString('MessageTaskScanItemsAdded', [scanResults.added])) + if (scanResults.updated) strs.push(this.$getString('MessageTaskScanItemsUpdated', [scanResults.updated])) + if (scanResults.missing) strs.push(this.$getString('MessageTaskScanItemsMissing', [scanResults.missing])) + const changesDetected = strs.length > 0 ? strs.join(', ') : this.$strings.MessageTaskScanNoChangesNeeded + const timeElapsed = scanResults.elapsed ? ` (${this.$elapsedPretty(scanResults.elapsed / 1000, false, true)})` : '' + this.specialMessage = `${changesDetected}${timeElapsed}` + } else { + this.specialMessage = '' + } + }, cancelScan() { const libraryId = this.task?.data?.libraryId if (!libraryId) { diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 2eb6b123c8..0ec5cccee4 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -89,10 +89,10 @@ Vue.prototype.$strings = { ...enUsStrings } * Get string and substitute * * @param {string} key - * @param {string[]} subs + * @param {string[]} [subs=[]] * @returns {string} */ -Vue.prototype.$getString = (key, subs) => { +Vue.prototype.$getString = (key, subs = []) => { if (!Vue.prototype.$strings[key]) return '' if (subs?.length && Array.isArray(subs)) { return supplant(Vue.prototype.$strings[key], subs) diff --git a/client/plugins/utils.js b/client/plugins/utils.js index 160ff9439c..ad08ebf6ad 100644 --- a/client/plugins/utils.js +++ b/client/plugins/utils.js @@ -18,7 +18,10 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] } -Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => { +Vue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds = false) => { + if (useMilliseconds && seconds > 0 && seconds < 1) { + return `${Math.floor(seconds * 1000)} ms` + } if (seconds < 60) { return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}` } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 9e1643e17f..6da92f6f64 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -777,6 +777,38 @@ "MessageShareExpiresIn": "Expires in {0}", "MessageShareURLWillBe": "Share URL will be {0}", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", + "MessageTaskAudioFileNotWritable": "Audio file \"{0}\" is not writable", + "MessageTaskCanceledByUser": "Task canceled by user", + "MessageTaskDownloadingEpisodeDescription": "Downloading episode \"{0}\"", + "MessageTaskEmbeddingMetadata": "Embedding metadata", + "MessageTaskEmbeddingMetadataDescription": "Embedding metadata in audiobook \"{0}\"", + "MessageTaskEncodingM4b": "Encoding M4B", + "MessageTaskEncodingM4bDescription": "Encoding audiobook \"{0}\" into a single m4b file", + "MessageTaskFailed": "Failed", + "MessageTaskFailedToBackupAudioFile": "Failed to backup audio file \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Failed to create cache directory", + "MessageTaskFailedToEmbedMetadataInFile": "Failed to embed metadata in file \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Failed to merge audio files", + "MessageTaskFailedToMoveM4bFile": "Failed to move m4b file", + "MessageTaskFailedToWriteMetadataFile": "Failed to write metadata file", + "MessageTaskMatchingBooksInLibrary": "Matching books in library \"{0}\"", + "MessageTaskNoFilesToScan": "No files to scan", + "MessageTaskOpmlImport": "OPML import", + "MessageTaskOpmlImportDescription": "Creating podcasts from {0} RSS feeds", + "MessageTaskOpmlImportFeed": "OPML import feed", + "MessageTaskOpmlImportFeedDescription": "Importing RSS feed \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "Failed to get podcast feed", + "MessageTaskOpmlImportFeedPodcastDescription": "Creating podcast \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "Podcast already exists at path", + "MessageTaskOpmlImportFeedPodcastFailed": "Failed to create podcast", + "MessageTaskOpmlImportFinished": "Added {0} podcasts", + "MessageTaskScanItemsAdded": "{0} added", + "MessageTaskScanItemsMissing": "{0} missing", + "MessageTaskScanItemsUpdated": "{0} updated", + "MessageTaskScanNoChangesNeeded": "No changes needed", + "MessageTaskScanningFileChanges": "Scanning file changes in \"{0}\"", + "MessageTaskScanningLibrary": "Scanning \"{0}\" library", + "MessageTaskTargetDirectoryNotWritable": "Target directory is not writable", "MessageThinking": "Thinking...", "MessageUploaderItemFailed": "Failed to upload", "MessageUploaderItemSuccess": "Successfully Uploaded!", diff --git a/server/Watcher.js b/server/Watcher.js index 0a5867bd65..0e34fc66bf 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -335,7 +335,11 @@ class FolderWatcher extends EventEmitter { if (this.pendingFileUpdates.length) { LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask) } else { - this.pendingTask.setFinished('Scan abandoned. No files to scan.') + const taskFinishedString = { + text: 'No files to scan', + key: 'MessageTaskNoFilesToScan' + } + this.pendingTask.setFinished(taskFinishedString) TaskManager.taskFinished(this.pendingTask) } this.pendingTask = null diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index 1fed95a18f..ea70d73c7e 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -188,10 +188,11 @@ class AbMergeManager { if (error.message === 'FFMPEG_CANCELED') { Logger.info(`[AbMergeManager] Task cancelled ${task.id}`) } else { - Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`) + Logger.error(`[AbMergeManager] Failed to embed metadata in file "${task.data.tempFilepath}"`) const taskFailedString = { - text: 'Failed to write metadata to m4b file', - key: 'MessageTaskFailedToWriteMetadataToM4bFile' + text: `Failed to embed metadata in file ${Path.basename(task.data.tempFilepath)}`, + key: 'MessageTaskFailedToEmbedMetadataInFile', + subs: [Path.basename(task.data.tempFilepath)] } task.setFailed(taskFailedString) this.removeTask(task, true) diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 8cd8039c8a..7911178e34 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -121,6 +121,10 @@ class AudioMetadataMangaer { } } + /** + * + * @param {import('../objects/Task')} task + */ async runMetadataEmbed(task) { this.tasksRunning.push(task) TaskManager.addTask(task) @@ -132,7 +136,11 @@ class AudioMetadataMangaer { Logger.debug(`[AudioMetadataManager] Target directory ${task.data.libraryItemDir} writable: ${targetDirWritable}`) if (!targetDirWritable) { Logger.error(`[AudioMetadataManager] Target directory is not writable: ${task.data.libraryItemDir}`) - task.setFailedText('Target directory is not writable') + const taskFailedString = { + text: 'Target directory is not writable', + key: 'MessageTaskTargetDirectoryNotWritable' + } + task.setFailed(taskFailedString) this.handleTaskFinished(task) return } @@ -143,7 +151,12 @@ class AudioMetadataMangaer { await fs.access(af.path, fs.constants.W_OK) } catch (err) { Logger.error(`[AudioMetadataManager] Audio file is not writable: ${af.path}`) - task.setFailedText(`Audio file "${Path.basename(af.path)}" is not writable`) + const taskFailedString = { + text: `Audio file "${Path.basename(af.path)}" is not writable`, + key: 'MessageTaskAudioFileNotWritable', + subs: [Path.basename(af.path)] + } + task.setFailed(taskFailedString) this.handleTaskFinished(task) return } @@ -157,7 +170,11 @@ class AudioMetadataMangaer { cacheDirCreated = true } catch (err) { Logger.error(`[AudioMetadataManager] Failed to create cache directory ${task.data.itemCachePath}`, err) - task.setFailedText('Failed to create cache directory') + const taskFailedString = { + text: 'Failed to create cache directory', + key: 'MessageTaskFailedToCreateCacheDirectory' + } + task.setFailed(taskFailedString) this.handleTaskFinished(task) return } @@ -168,7 +185,11 @@ class AudioMetadataMangaer { const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath) if (!success) { Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`) - task.setFailedText('Failed to write metadata file.') + const taskFailedString = { + text: 'Failed to write metadata file', + key: 'MessageTaskFailedToWriteMetadataFile' + } + task.setFailed(taskFailedString) this.handleTaskFinished(task) return } @@ -190,7 +211,12 @@ class AudioMetadataMangaer { Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`) } catch (err) { Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err) - task.setFailedText(`Failed to backup audio file "${Path.basename(af.path)}"`) + const taskFailedString = { + text: `Failed to backup audio file "${Path.basename(af.path)}"`, + key: 'MessageTaskFailedToBackupAudioFile', + subs: [Path.basename(af.path)] + } + task.setFailed(taskFailedString) this.handleTaskFinished(task) return } @@ -204,7 +230,12 @@ class AudioMetadataMangaer { Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`) } catch (err) { Logger.error(`[AudioMetadataManager] Failed to tag audio file "${af.path}"`, err) - task.setFailedText(`Failed to tag audio file "${Path.basename(af.path)}"`) + const taskFailedString = { + text: `Failed to embed metadata in file "${Path.basename(af.path)}"`, + key: 'MessageTaskFailedToEmbedMetadataInFile', + subs: [Path.basename(af.path)] + } + task.setFailed(taskFailedString) this.handleTaskFinished(task) return } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 9e0bdbc2dc..4e6c3fb839 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -127,14 +127,22 @@ class PodcastManager { if (!success) { await fs.remove(this.currentDownload.targetPath) this.currentDownload.setFinished(false) - task.setFailedText('Failed to download episode') + const taskFailedString = { + text: 'Failed', + key: 'MessageTaskFailed' + } + task.setFailed(taskFailedString) } else { Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`) this.currentDownload.setFinished(true) task.setFinished() } } else { - task.setFailedText('Failed to download episode') + const taskFailedString = { + text: 'Failed', + key: 'MessageTaskFailed' + } + task.setFailed(taskFailedString) this.currentDownload.setFinished(false) } @@ -560,7 +568,12 @@ class PodcastManager { numPodcastsAdded++ } - task.setFinished(`Added ${numPodcastsAdded} podcasts`) + const taskFinishedString = { + text: `Added ${numPodcastsAdded} podcasts`, + key: 'MessageTaskOpmlImportFinished', + subs: [numPodcastsAdded] + } + task.setFinished(taskFinishedString) TaskManager.taskFinished(task) Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`) } diff --git a/server/managers/TaskManager.js b/server/managers/TaskManager.js index 52c093a92b..5067f841aa 100644 --- a/server/managers/TaskManager.js +++ b/server/managers/TaskManager.js @@ -63,7 +63,7 @@ class TaskManager { createAndEmitFailedTask(action, titleString, descriptionString, errorMessageString) { const task = new Task() task.setData(action, titleString, descriptionString, false) - task.setFailedText(errorMessageString) + task.setFailed(errorMessageString) SocketAuthority.emitter('task_started', task.toJSON()) return task } diff --git a/server/objects/Task.js b/server/objects/Task.js index 0409cad62c..e6fb396363 100644 --- a/server/objects/Task.js +++ b/server/objects/Task.js @@ -57,8 +57,14 @@ class Task { action: this.action, data: this.data ? { ...this.data } : {}, title: this.title, + titleKey: this.titleKey, + titleSubs: this.titleSubs, description: this.description, + descriptionKey: this.descriptionKey, + descriptionSubs: this.descriptionSubs, error: this.error, + errorKey: this.errorKey, + errorSubs: this.errorSubs, showSuccess: this.showSuccess, isFailed: this.isFailed, isFinished: this.isFinished, @@ -104,30 +110,19 @@ class Task { this.setFinished() } - /** - * Set task as failed without translation key - * TODO: Remove this method after all tasks are using translation keys - * - * @param {string} message - */ - setFailedText(message) { - this.error = message - this.errorKey = null - this.errorSubs = null - this.isFailed = true - this.failedAt = Date.now() - this.setFinished() - } - /** * Set task as finished - * TODO: Update to use translation keys * - * @param {string} [newDescription] update description + * @param {TaskString} [newDescriptionString] update description + * @param {boolean} [clearDescription] clear description */ - setFinished(newDescription = null) { - if (newDescription) { - this.description = newDescription + setFinished(newDescriptionString = null, clearDescription = false) { + if (newDescriptionString) { + this.description = newDescriptionString.text + this.descriptionKey = newDescriptionString.key || null + this.descriptionSubs = newDescriptionString.subs || null + } else if (clearDescription) { + this.description = null this.descriptionKey = null this.descriptionSubs = null } diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index 8994aa231c..220c6eb4a9 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -18,7 +18,6 @@ class LibraryScan { this.startedAt = null this.finishedAt = null this.elapsed = null - this.error = null this.resultsMissing = 0 this.resultsAdded = 0 @@ -55,22 +54,6 @@ class LibraryScan { get elapsedTimestamp() { return secondsToTimestamp(this.elapsed / 1000) } - get getScanEmitData() { - return { - id: this.libraryId, - type: this.type, - name: this.libraryName, - error: this.error, - results: { - added: this.resultsAdded, - updated: this.resultsUpdated, - missing: this.resultsMissing - } - } - } - get totalResults() { - return this.resultsAdded + this.resultsUpdated + this.resultsMissing - } get logFilename() { return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt' } @@ -79,10 +62,19 @@ class LibraryScan { if (this.resultsAdded) strs.push(`${this.resultsAdded} added`) if (this.resultsUpdated) strs.push(`${this.resultsUpdated} updated`) if (this.resultsMissing) strs.push(`${this.resultsMissing} missing`) - const changesDetected = strs.length > 0 ? strs.join(', ') : 'No changes detected' + const changesDetected = strs.length > 0 ? strs.join(', ') : 'No changes needed' const timeElapsed = `(${elapsedPretty(this.elapsed / 1000)})` - const error = this.error ? `${this.error}. ` : '' - return `${error}${changesDetected} ${timeElapsed}` + return `${changesDetected} ${timeElapsed}` + } + + get scanResults() { + return { + added: this.resultsAdded, + updated: this.resultsUpdated, + missing: this.resultsMissing, + elapsed: this.elapsed, + text: this.scanResultsString + } } toJSON() { @@ -93,7 +85,6 @@ class LibraryScan { startedAt: this.startedAt, finishedAt: this.finishedAt, elapsed: this.elapsed, - error: this.error, resultsAdded: this.resultsAdded, resultsUpdated: this.resultsUpdated, resultsMissing: this.resultsMissing @@ -113,14 +104,9 @@ class LibraryScan { this.startedAt = Date.now() } - /** - * - * @param {string} error - */ - setComplete(error = null) { + setComplete() { this.finishedAt = Date.now() this.elapsed = this.finishedAt - this.startedAt - this.error = error } getLogLevelString(level) { diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 6b9f7893e1..b8fcd99e18 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -18,6 +18,7 @@ const Task = require('../objects/Task') class LibraryScanner { constructor() { this.cancelLibraryScan = {} + /** @type {string[]} - library ids */ this.librariesScanning = [] this.scanningFilesChanged = false @@ -30,7 +31,7 @@ class LibraryScanner { * @returns {boolean} */ isLibraryScanning(libraryId) { - return this.librariesScanning.some((ls) => ls.id === libraryId) + return this.librariesScanning.some((lid) => lid === libraryId) } /** @@ -38,8 +39,7 @@ class LibraryScanner { * @param {string} libraryId */ setCancelLibraryScan(libraryId) { - const libraryScanning = this.librariesScanning.find((ls) => ls.id === libraryId) - if (!libraryScanning) return + if (!this.isLibraryScanning(libraryId)) return this.cancelLibraryScan[libraryId] = true } @@ -69,7 +69,7 @@ class LibraryScanner { const libraryScan = new LibraryScan() libraryScan.setData(library) libraryScan.verbose = true - this.librariesScanning.push(libraryScan.getScanEmitData) + this.librariesScanning.push(libraryScan.libraryId) const taskData = { libraryId: library.id, @@ -103,17 +103,31 @@ class LibraryScanner { await library.save() } - task.setFinished(`${canceled ? 'Canceled' : 'Completed'}. ${libraryScan.scanResultsString}`) + task.data.scanResults = libraryScan.scanResults + if (canceled) { + const taskFinishedString = { + text: 'Task canceled by user', + key: 'MessageTaskCanceledByUser' + } + task.setFinished(taskFinishedString) + } else { + task.setFinished(null, true) + } } catch (err) { - libraryScan.setComplete(err) + libraryScan.setComplete() Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}.`, err) - task.setFailedText(`Failed. ${libraryScan.scanResultsString}`) + task.data.scanResults = libraryScan.scanResults + const taskFailedString = { + text: 'Failed', + key: 'MessageTaskFailed' + } + task.setFailed(taskFailedString) } if (this.cancelLibraryScan[libraryScan.libraryId]) delete this.cancelLibraryScan[libraryScan.libraryId] - this.librariesScanning = this.librariesScanning.filter((ls) => ls.id !== library.id) + this.librariesScanning = this.librariesScanning.filter((lid) => lid !== library.id) TaskManager.taskFinished(task) @@ -446,9 +460,15 @@ class LibraryScanner { if (results.added) resultStrs.push(`${results.added} added`) if (results.updated) resultStrs.push(`${results.updated} updated`) if (results.removed) resultStrs.push(`${results.removed} missing`) - let scanResultStr = 'Scan finished with no changes' + let scanResultStr = 'No changes needed' if (resultStrs.length) scanResultStr = resultStrs.join(', ') - pendingTask.setFinished(scanResultStr) + + pendingTask.data.scanResults = { + ...results, + text: scanResultStr, + elapsed: Date.now() - pendingTask.startedAt + } + pendingTask.setFinished(null, true) TaskManager.taskFinished(pendingTask) this.scanningFilesChanged = false diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 6bb62706d7..cfdeb1402a 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -364,7 +364,7 @@ class Scanner { const libraryScan = new LibraryScan() libraryScan.setData(library, 'match') - LibraryScanner.librariesScanning.push(libraryScan.getScanEmitData) + LibraryScanner.librariesScanning.push(libraryScan.libraryId) const taskData = { libraryId: library.id } @@ -397,15 +397,29 @@ class Scanner { if (offset === 0) { Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`) - libraryScan.setComplete('Library has no items') - task.setFailedText(libraryScan.error) + libraryScan.setComplete() + const taskFailedString = { + text: 'No items found', + key: 'MessageNoItemsFound' + } + task.setFailed(taskFailedString) } else { libraryScan.setComplete() - task.setFinished(isCanceled ? 'Canceled' : libraryScan.scanResultsString) + + task.data.scanResults = libraryScan.scanResults + if (isCanceled) { + const taskFinishedString = { + text: 'Task canceled by user', + key: 'MessageTaskCanceledByUser' + } + task.setFinished(taskFinishedString) + } else { + task.setFinished(null, true) + } } delete LibraryScanner.cancelLibraryScan[libraryScan.libraryId] - LibraryScanner.librariesScanning = LibraryScanner.librariesScanning.filter((ls) => ls.id !== library.id) + LibraryScanner.librariesScanning = LibraryScanner.librariesScanning.filter((lid) => lid !== library.id) TaskManager.taskFinished(task) } } From decde230aa78b1838a91645229e3b189e0f1b1cd Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 22 Sep 2024 14:15:17 -0500 Subject: [PATCH 133/539] Update:Some logs to include library item id #3440 --- server/controllers/LibraryItemController.js | 2 +- server/scanner/LibraryItemScanner.js | 45 +++++++++++---------- server/scanner/LibraryScanner.js | 2 +- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index c77e1d3a59..6550b9e93d 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -480,7 +480,7 @@ class LibraryItemController { const libraryId = itemsToDelete[0].libraryId for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path - Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}"`) + Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) if (hardDelete) { diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 1c3123df1e..38608e479f 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -15,12 +15,12 @@ const LibraryFile = require('../objects/files/LibraryFile') const SocketAuthority = require('../SocketAuthority') class LibraryItemScanner { - constructor() { } + constructor() {} /** * Scan single library item - * - * @param {string} libraryItemId + * + * @param {string} libraryItemId * @param {{relPath:string, path:string}} [updateLibraryItemDetails] used by watcher when item folder was renamed * @returns {number} ScanResult */ @@ -76,8 +76,8 @@ class LibraryItemScanner { /** * Remove empty authors and series - * @param {string} libraryId - * @param {ScanLogger} scanLogger + * @param {string} libraryId + * @param {ScanLogger} scanLogger * @returns {Promise} */ async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) { @@ -90,11 +90,11 @@ class LibraryItemScanner { } /** - * - * @param {string} libraryItemPath - * @param {import('../models/Library')} library - * @param {import('../models/LibraryFolder')} folder - * @param {boolean} isSingleMediaItem + * + * @param {string} libraryItemPath + * @param {import('../models/Library')} library + * @param {import('../models/LibraryFolder')} folder + * @param {boolean} isSingleMediaItem * @returns {Promise} */ async getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem) { @@ -105,7 +105,8 @@ class LibraryItemScanner { let fileItems = [] - if (isSingleMediaItem) { // Single media item in root of folder + if (isSingleMediaItem) { + // Single media item in root of folder fileItems = [ { fullpath: libraryItemPath, @@ -151,9 +152,9 @@ class LibraryItemScanner { } /** - * - * @param {import('../models/LibraryItem')} existingLibraryItem - * @param {LibraryItemScanData} libraryItemData + * + * @param {import('../models/LibraryItem')} existingLibraryItem + * @param {LibraryItemScanData} libraryItemData * @param {import('../models/Library').LibrarySettingsObject} librarySettings * @param {LibraryScan} libraryScan * @returns {Promise<{libraryItem:LibraryItem, wasUpdated:boolean}>} @@ -167,8 +168,8 @@ class LibraryItemScanner { } /** - * - * @param {LibraryItemScanData} libraryItemData + * + * @param {LibraryItemScanData} libraryItemData * @param {import('../models/Library').LibrarySettingsObject} librarySettings * @param {LibraryScan} libraryScan * @returns {Promise} @@ -181,17 +182,17 @@ class LibraryItemScanner { newLibraryItem = await PodcastScanner.scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan) } if (newLibraryItem) { - libraryScan.addLog(LogLevel.INFO, `Created new library item "${newLibraryItem.relPath}"`) + libraryScan.addLog(LogLevel.INFO, `Created new library item "${newLibraryItem.relPath}" with id "${newLibraryItem.id}"`) } return newLibraryItem } /** * Scan library item folder coming from Watcher - * @param {string} libraryItemPath - * @param {import('../models/Library')} library - * @param {import('../models/LibraryFolder')} folder - * @param {boolean} isSingleMediaItem + * @param {string} libraryItemPath + * @param {import('../models/Library')} library + * @param {import('../models/LibraryFolder')} folder + * @param {boolean} isSingleMediaItem * @returns {Promise} ScanResult */ async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) { @@ -204,4 +205,4 @@ class LibraryItemScanner { return this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger) } } -module.exports = new LibraryItemScanner() \ No newline at end of file +module.exports = new LibraryItemScanner() diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index b8fcd99e18..bd0bb310f5 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -618,7 +618,7 @@ class LibraryScanner { } } // Scan library item for updates - Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`) + Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" with id "${existingLibraryItem.id}" - scan for updates`) itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails) continue } else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) { From 5b22e945dab1d31ecb7727d26e2456f75151d1d0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 23 Sep 2024 16:36:56 -0500 Subject: [PATCH 134/539] Update:Format numbers on user listening stats chart #3441 --- client/components/stats/DailyListeningChart.vue | 8 ++++---- server/controllers/LibraryItemController.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/components/stats/DailyListeningChart.vue b/client/components/stats/DailyListeningChart.vue index d968132438..ca2cd714b4 100644 --- a/client/components/stats/DailyListeningChart.vue +++ b/client/components/stats/DailyListeningChart.vue @@ -35,22 +35,22 @@

{{ $strings.LabelStatsWeekListening }}

-

{{ totalMinutesListeningThisWeek }}

+

{{ $formatNumber(totalMinutesListeningThisWeek) }}

{{ $strings.LabelStatsMinutes }}

{{ $strings.LabelStatsDailyAverage }}

-

{{ averageMinutesPerDay }}

+

{{ $formatNumber(averageMinutesPerDay) }}

{{ $strings.LabelStatsMinutes }}

{{ $strings.LabelStatsBestDay }}

-

{{ mostListenedDay }}

+

{{ $formatNumber(mostListenedDay) }}

{{ $strings.LabelStatsMinutes }}

{{ $strings.LabelStatsDays }}

-

{{ daysInARow }}

+

{{ $formatNumber(daysInARow) }}

{{ $strings.LabelStatsInARow }}

diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 6550b9e93d..fe8539bc32 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -480,7 +480,7 @@ class LibraryItemController { const libraryId = itemsToDelete[0].libraryId for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path - Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) + Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) if (hardDelete) { From bb7938f66d816709635fb132faa85d23479e922a Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 24 Sep 2024 10:54:25 -0500 Subject: [PATCH 135/539] Update:When merging embedded chapters from multiple files filter out ~0 duration chapters #3361 --- server/scanner/AudioFileScanner.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index 1cd148f6fa..2a70e6a071 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -475,16 +475,26 @@ class AudioFileScanner { audioFiles.forEach((file) => { if (file.duration) { - const afChapters = - file.chapters?.map((c) => ({ - ...c, - id: c.id + currChapterId, - start: c.start + currStartTime, - end: c.end + currStartTime - })) ?? [] + // Multi-file audiobook may include the previous and next chapters embedded with close to 0 duration + // Filter these out and log a warning + // See https://github.com/advplyr/audiobookshelf/issues/3361 + const afChaptersCleaned = + file.chapters?.filter((c) => { + if (c.end - c.start < 0.1) { + libraryScan.addLog(LogLevel.WARN, `Chapter "${c.title}" has invalid duration of ${c.end - c.start} seconds. Skipping this chapter.`) + return false + } + return true + }) || [] + const afChapters = afChaptersCleaned.map((c) => ({ + ...c, + id: c.id + currChapterId, + start: c.start + currStartTime, + end: c.end + currStartTime + })) chapters = chapters.concat(afChapters) - currChapterId += file.chapters?.length ?? 0 + currChapterId += afChaptersCleaned.length ?? 0 currStartTime += file.duration } }) From 8a7b5cc87d0e8351569fa4e8772da781327d7cd4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 24 Sep 2024 16:47:09 -0500 Subject: [PATCH 136/539] Ensure series-column-unique migration is idempotent --- server/managers/MigrationManager.js | 3 +-- .../v2.13.5-series-column-unique.js | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index 706e359cf1..beaf8a4d8c 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -38,6 +38,7 @@ class MigrationManager { if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`) this.migrationsDir = path.join(this.configPath, 'migrations') + await fs.ensureDir(this.migrationsDir) this.serverVersion = this.extractVersionFromTag(serverVersion) if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`) @@ -222,8 +223,6 @@ class MigrationManager { } async copyMigrationsToConfigDir() { - await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists - if (!(await fs.pathExists(this.migrationsSourceDir))) return const files = await fs.readdir(this.migrationsSourceDir) diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js index 1860772d2a..2724221aba 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -16,7 +16,15 @@ */ async function up({ context: { queryInterface, logger } }) { // Upwards migration script - logger.info('UPGRADE BEGIN: 2.13.5-series-column-unique ') + logger.info('[2.13.5 migration] UPGRADE BEGIN: 2.13.5-series-column-unique ') + + // Check if the unique index already exists + const seriesIndexes = await queryInterface.showIndex('Series') + if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) { + logger.info('[2.13.5 migration] Unique index on Series.name and Series.libraryId already exists') + logger.info('[2.13.5 migration] UPGRADE END: 2.13.5-series-column-unique ') + return + } // The steps taken to deduplicate the series are as follows: // 1. Find all duplicate series in the `Series` table. @@ -173,9 +181,9 @@ async function up({ context: { queryInterface, logger } }) { unique: true, name: 'unique_series_name_per_library' }) - logger.info('Added unique index on Series.name and Series.libraryId') + logger.info('[2.13.5 migration] Added unique index on Series.name and Series.libraryId') - logger.info('UPGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.13.5 migration] UPGRADE END: 2.13.5-series-column-unique ') } /** @@ -186,13 +194,13 @@ async function up({ context: { queryInterface, logger } }) { */ async function down({ context: { queryInterface, logger } }) { // Downward migration script - logger.info('DOWNGRADE BEGIN: 2.13.5-series-column-unique ') + logger.info('[2.13.5 migration] DOWNGRADE BEGIN: 2.13.5-series-column-unique ') // Remove the unique index await queryInterface.removeIndex('Series', 'unique_series_name_per_library') - logger.info('Removed unique index on Series.name and Series.libraryId') + logger.info('[2.13.5 migration] Removed unique index on Series.name and Series.libraryId') - logger.info('DOWNGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.13.5 migration] DOWNGRADE END: 2.13.5-series-column-unique ') } module.exports = { up, down } From c67b5e950edbd86e74708e92d3c58c700bea5f1b Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 24 Sep 2024 16:54:13 -0500 Subject: [PATCH 137/539] Update MigrationManager.test.js - moved migrations ensureDir to init() --- test/server/managers/MigrationManager.test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/server/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js index ae94cd75cc..af2e9da8f0 100644 --- a/test/server/managers/MigrationManager.test.js +++ b/test/server/managers/MigrationManager.test.js @@ -63,6 +63,8 @@ describe('MigrationManager', () => { await migrationManager.init(serverVersion) // Assert + expect(fsEnsureDirStub.calledOnce).to.be.true + expect(fsEnsureDirStub.calledWith(migrationManager.migrationsDir)).to.be.true expect(migrationManager.serverVersion).to.equal(serverVersion) expect(migrationManager.sequelize).to.equal(sequelizeStub) expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations')) @@ -353,8 +355,6 @@ describe('MigrationManager', () => { await migrationManager.copyMigrationsToConfigDir() // Assert - expect(fsEnsureDirStub.calledOnce).to.be.true - expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true expect(readdirStub.calledOnce).to.be.true expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true expect(fsCopyStub.calledTwice).to.be.true @@ -382,8 +382,6 @@ describe('MigrationManager', () => { } catch (error) {} // Assert - expect(fsEnsureDirStub.calledOnce).to.be.true - expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true expect(readdirStub.calledOnce).to.be.true expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true expect(fsCopyStub.calledTwice).to.be.true From 5154e31c1cba8bad9a088f5586632fabb3abd656 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 24 Sep 2024 17:06:00 -0500 Subject: [PATCH 138/539] Update migration to v2.14.0 --- server/migrations/changelog.md | 2 +- ...que.js => v2.14.0-series-column-unique.js} | 26 ++--- ...s => v2.14.0-series-column-unique.test.js} | 102 +++++++++--------- 3 files changed, 65 insertions(+), 65 deletions(-) rename server/migrations/{v2.13.5-series-column-unique.js => v2.14.0-series-column-unique.js} (88%) rename test/server/migrations/{v2.13.5-series-column-unique.test.js => v2.14.0-series-column-unique.test.js} (78%) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index bac3ec25e5..3ab52ac3ef 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -4,4 +4,4 @@ Please add a record of every database migration that you create to this file. Th | Server Version | Migration Script Name | Description | | -------------- | ---------------------------- | ------------------------------------------------- | -| v2.13.5 | v2.13.5-series-column-unique | Series must have unique names in the same library | +| v2.14.0 | v2.14.0-series-column-unique | Series must have unique names in the same library | diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.14.0-series-column-unique.js similarity index 88% rename from server/migrations/v2.13.5-series-column-unique.js rename to server/migrations/v2.14.0-series-column-unique.js index 2724221aba..489b670bb8 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.14.0-series-column-unique.js @@ -16,13 +16,13 @@ */ async function up({ context: { queryInterface, logger } }) { // Upwards migration script - logger.info('[2.13.5 migration] UPGRADE BEGIN: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique ') // Check if the unique index already exists const seriesIndexes = await queryInterface.showIndex('Series') if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) { - logger.info('[2.13.5 migration] Unique index on Series.name and Series.libraryId already exists') - logger.info('[2.13.5 migration] UPGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] Unique index on Series.name and Series.libraryId already exists') + logger.info('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique ') return } @@ -43,12 +43,12 @@ async function up({ context: { queryInterface, logger } }) { `) // Print out how many duplicates were found - logger.info(`[2.13.5 migration] Found ${duplicates.length} duplicate series`) + logger.info(`[2.14.0 migration] Found ${duplicates.length} duplicate series`) // Iterate over each duplicate series for (const duplicate of duplicates) { // Report the series name that is being deleted - logger.info(`[2.13.5 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) + logger.info(`[2.14.0 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) // Determine any duplicate book IDs in the `bookSeries` table for the same series const [duplicateBookIds] = await queryInterface.sequelize.query( @@ -73,7 +73,7 @@ async function up({ context: { queryInterface, logger } }) { // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId for (const { bookId } of duplicateBookIds) { - logger.info(`[2.13.5 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) + logger.info(`[2.14.0 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last const [duplicateBookSeries] = await queryInterface.sequelize.query( ` @@ -113,7 +113,7 @@ async function up({ context: { queryInterface, logger } }) { } ) } - logger.info(`[2.13.5 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) + logger.info(`[2.14.0 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) } // Get all the most recent series which matches the `name` and `libraryId` @@ -174,16 +174,16 @@ async function up({ context: { queryInterface, logger } }) { } } - logger.info(`[2.13.5 migration] Deduplication complete`) + logger.info(`[2.14.0 migration] Deduplication complete`) // Create a unique index based on the name and library ID for the `Series` table await queryInterface.addIndex('Series', ['name', 'libraryId'], { unique: true, name: 'unique_series_name_per_library' }) - logger.info('[2.13.5 migration] Added unique index on Series.name and Series.libraryId') + logger.info('[2.14.0 migration] Added unique index on Series.name and Series.libraryId') - logger.info('[2.13.5 migration] UPGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique ') } /** @@ -194,13 +194,13 @@ async function up({ context: { queryInterface, logger } }) { */ async function down({ context: { queryInterface, logger } }) { // Downward migration script - logger.info('[2.13.5 migration] DOWNGRADE BEGIN: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] DOWNGRADE BEGIN: 2.14.0-series-column-unique ') // Remove the unique index await queryInterface.removeIndex('Series', 'unique_series_name_per_library') - logger.info('[2.13.5 migration] Removed unique index on Series.name and Series.libraryId') + logger.info('[2.14.0 migration] Removed unique index on Series.name and Series.libraryId') - logger.info('[2.13.5 migration] DOWNGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] DOWNGRADE END: 2.14.0-series-column-unique ') } module.exports = { up, down } diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.14.0-series-column-unique.test.js similarity index 78% rename from test/server/migrations/v2.13.5-series-column-unique.test.js rename to test/server/migrations/v2.14.0-series-column-unique.test.js index 3c5b1b0407..43acc9278b 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.14.0-series-column-unique.test.js @@ -1,13 +1,13 @@ const { expect } = require('chai') const sinon = require('sinon') -const { up, down } = require('../../../server/migrations/v2.13.5-series-column-unique') +const { up, down } = require('../../../server/migrations/v2.14.0-series-column-unique') const { Sequelize } = require('sequelize') const Logger = require('../../../server/Logger') const { query } = require('express') const { logger } = require('sequelize/lib/utils/logger') const e = require('express') -describe('migration-v2.13.5-series-column-unique', () => { +describe('migration-v2.14.0-series-column-unique', () => { let sequelize let queryInterface let loggerInfoStub @@ -105,11 +105,11 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(5) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // Validate rows in tables const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(3) @@ -145,13 +145,13 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(7) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 2 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 3" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 2 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 3" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // Validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(3) @@ -182,11 +182,11 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(5) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // Validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(2) @@ -212,14 +212,14 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -244,14 +244,14 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -275,14 +275,14 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -319,14 +319,14 @@ describe('migration-v2.13.5-series-column-unique', () => { await down({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('DOWNGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Removed unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(7).calledWith(sinon.match('DOWNGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] DOWNGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] Removed unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.14.0 migration] DOWNGRADE END: 2.14.0-series-column-unique '))).to.be.true // Ensure index does not exist const indexes = await queryInterface.showIndex('Series') expect(indexes).to.not.deep.include({ tableName: 'Series', unique: true, fields: ['name', 'libraryId'], name: 'unique_series_name_per_library' }) From 0d31d20f0f335cc7fde10ee938c2cae5a8f7ecc9 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Tue, 24 Sep 2024 23:00:19 +0000 Subject: [PATCH 139/539] Center align player chapter title --- client/components/player/PlayerUi.vue | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index 968b0ea1f2..c3bb4cdacf 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -48,15 +48,19 @@ -
-

00:00:00

- -
-

- {{ currentChapterName }}  ({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }}) -

-
-

{{ timeRemainingPretty }}

+
+
+

00:00:00

+ +
+
+

+ {{ currentChapterName }}  ({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }}) +

+
+
+

{{ timeRemainingPretty }}

+
From b2d41f05839c0b12cedefc305239e760c217ab14 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Tue, 24 Sep 2024 23:17:26 +0000 Subject: [PATCH 140/539] Move playback speed control next to player volume control --- .../player/PlayerPlaybackControls.vue | 83 +++++++------------ client/components/player/PlayerUi.vue | 8 ++ 2 files changed, 40 insertions(+), 51 deletions(-) diff --git a/client/components/player/PlayerPlaybackControls.vue b/client/components/player/PlayerPlaybackControls.vue index 39ce4f3c40..1b96a24b23 100644 --- a/client/components/player/PlayerPlaybackControls.vue +++ b/client/components/player/PlayerPlaybackControls.vue @@ -1,38 +1,37 @@ @@ -41,7 +40,6 @@ export default { props: { loading: Boolean, seekLoading: Boolean, - playbackRate: Number, paused: Boolean, hasNextChapter: Boolean, hasNextItemInQueue: Boolean @@ -50,14 +48,6 @@ export default { return {} }, computed: { - playbackRateInput: { - get() { - return this.playbackRate - }, - set(val) { - this.$emit('update:playbackRate', val) - } - }, jumpForwardText() { return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward) }, @@ -89,15 +79,6 @@ export default { jumpForward() { this.$emit('jumpForward') }, - playbackRateUpdated(playbackRate) { - this.$emit('setPlaybackRate', playbackRate) - }, - playbackRateChanged(playbackRate) { - this.$emit('setPlaybackRate', playbackRate) - this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => { - console.error('Failed to update settings', err) - }) - }, getJumpText(setting, prefix) { const amount = this.$store.getters['user/getUserSetting'](setting) if (!amount) return prefix diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index c3bb4cdacf..e341da4a3f 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -4,6 +4,8 @@
+ + @@ -228,6 +230,12 @@ export default { this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1)) this.setPlaybackRate(this.playbackRate) }, + playbackRateChanged(playbackRate) { + this.setPlaybackRate(playbackRate) + this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => { + console.error('Failed to update settings', err) + }) + }, setPlaybackRate(playbackRate) { this.$emit('setPlaybackRate', playbackRate) }, From 0bc58c254f5ae1df66811b66b682eceb6489f236 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 25 Sep 2024 16:49:24 -0500 Subject: [PATCH 141/539] Update playback speed to no longer use font-mono, adjust position of popup --- .../components/controls/PlaybackSpeedControl.vue | 16 ++++++++-------- client/components/player/PlayerUi.vue | 4 +--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/client/components/controls/PlaybackSpeedControl.vue b/client/components/controls/PlaybackSpeedControl.vue index c4cc29010f..9e9f0d54c9 100644 --- a/client/components/controls/PlaybackSpeedControl.vue +++ b/client/components/controls/PlaybackSpeedControl.vue @@ -1,9 +1,9 @@ - @@ -43,11 +82,20 @@ export default { data() { return { + loading: false, password: null, newPassword: null, confirmPassword: null, changingPassword: false, - selectedLanguage: '' + selectedLanguage: '', + newEReaderDevice: { + name: '', + email: '' + }, + ereaderDevices: [], + deletingDeviceName: null, + selectedEReaderDevice: null, + showEReaderDeviceModal: false } }, computed: { @@ -75,6 +123,12 @@ export default { }, showChangePasswordForm() { return !this.isGuest && this.isPasswordAuthEnabled + }, + showEreaderTable() { + return this.usertype !== 'root' && this.usertype !== 'admin' && this.user.permissions?.createEreader + }, + revisedEreaderDevices() { + return this.ereaderDevices.filter((device) => device.users?.length === 1) } }, methods: { @@ -142,10 +196,52 @@ export default { this.$toast.error(this.$strings.ToastUnknownError) this.changingPassword = false }) + }, + addNewDeviceClick() { + this.selectedEReaderDevice = null + this.showEReaderDeviceModal = true + }, + editDeviceClick(device) { + this.selectedEReaderDevice = device + this.showEReaderDeviceModal = true + }, + deleteDeviceClick(device) { + const payload = { + message: this.$getString('MessageConfirmDeleteDevice', [device.name]), + callback: (confirmed) => { + if (confirmed) { + this.deleteDevice(device) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + deleteDevice(device) { + const payload = { + ereaderDevices: this.revisedEreaderDevices.filter((d) => d.name !== device.name) + } + this.deletingDeviceName = device.name + this.$axios + .$post(`/api/me/ereader-devices`, payload) + .then((data) => { + this.ereaderDevicesUpdated(data.ereaderDevices) + }) + .catch((error) => { + console.error('Failed to delete device', error) + this.$toast.error(this.$strings.ToastRemoveFailed) + }) + .finally(() => { + this.deletingDeviceName = null + }) + }, + ereaderDevicesUpdated(ereaderDevices) { + this.ereaderDevices = ereaderDevices } }, mounted() { this.selectedLanguage = this.$languageCodes.current + this.ereaderDevices = this.$store.state.libraries.ereaderDevices || [] } } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 918bf68560..8eb375500d 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Can Access All Libraries", "LabelPermissionsAccessAllTags": "Can Access All Tags", "LabelPermissionsAccessExplicitContent": "Can Access Explicit Content", + "LabelPermissionsCreateEreader": "Can Create Ereader", "LabelPermissionsDelete": "Can Delete", "LabelPermissionsDownload": "Can Download", "LabelPermissionsUpdate": "Can Update", diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index c7abbc2327..cc67b320d7 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -394,6 +394,58 @@ class MeController { res.json(req.user.toOldJSONForBrowser()) } + /** + * POST: /api/me/ereader-devices + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async updateUserEReaderDevices(req, res) { + if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) { + return res.status(400).send('Invalid payload. ereaderDevices array required') + } + + const userEReaderDevices = req.body.ereaderDevices + for (const device of userEReaderDevices) { + if (!device.name || !device.email) { + return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email') + } else if (device.availabilityOption !== 'specificUsers' || device.users?.length !== 1 || device.users[0] !== req.user.id) { + return res.status(400).send('Invalid payload. ereaderDevices array items must have availabilityOption "specificUsers" and only the current user') + } + } + + const otherDevices = Database.emailSettings.ereaderDevices.filter((device) => { + return !Database.emailSettings.checkUserCanAccessDevice(device, req.user) || device.users?.length !== 1 + }) + + const ereaderDevices = otherDevices.concat(userEReaderDevices) + + // Check for duplicate names + const nameSet = new Set() + const hasDupes = ereaderDevices.some((device) => { + if (nameSet.has(device.name)) { + return true // Duplicate found + } + nameSet.add(device.name) + return false + }) + + if (hasDupes) { + return res.status(400).send('Invalid payload. Duplicate "name" field found.') + } + + const updated = Database.emailSettings.update({ ereaderDevices }) + if (updated) { + await Database.updateSetting(Database.emailSettings) + SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', { + ereaderDevices: Database.emailSettings.ereaderDevices + }) + } + res.json({ + ereaderDevices: Database.emailSettings.getEReaderDevices(req.user) + }) + } + /** * GET: /api/me/stats/year/:year * diff --git a/server/models/User.js b/server/models/User.js index 8bd3f742d6..906a7d68e7 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -82,6 +82,7 @@ class User extends Model { canAccessExplicitContent: 'accessExplicitContent', canAccessAllLibraries: 'accessAllLibraries', canAccessAllTags: 'accessAllTags', + canCreateEReader: 'createEreader', tagsAreDenylist: 'selectedTagsNotAccessible', // Direct mapping for array-based permissions allowedLibraries: 'librariesAccessible', @@ -122,6 +123,7 @@ class User extends Model { update: type === 'root' || type === 'admin', delete: type === 'root', upload: type === 'root' || type === 'admin', + createEreader: type === 'root' || type === 'admin', accessAllLibraries: true, accessAllTags: true, accessExplicitContent: type === 'root' || type === 'admin', diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 57067ad8bb..f81bc26df5 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -190,6 +190,7 @@ class ApiRouter { this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this)) + this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this)) // // Backup Routes From 39be3a2ef93acb8ee43f2d4795abdf1dccb55652 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sat, 19 Oct 2024 18:02:33 +0000 Subject: [PATCH 292/539] Translated using Weblate (Croatian) Currently translated at 100.0% (1064 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index cd15ec677e..92636a1d49 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -252,9 +252,9 @@ "LabelBackupsNumberToKeepHelp": "Moguće je izbrisati samo jednu po jednu sigurnosnu kopiju, ako ih već imate više trebat ćete ih ručno ukloniti.", "LabelBitrate": "Protok", "LabelBonus": "Bonus", - "LabelBooks": "knjiga/e", + "LabelBooks": "Knjige", "LabelButtonText": "Tekst gumba", - "LabelByAuthor": "po {0}", + "LabelByAuthor": "autor: {0}", "LabelChangePassword": "Promijeni zaporku", "LabelChannels": "Kanali", "LabelChapterCount": "{0} Poglavlje/a", @@ -268,7 +268,7 @@ "LabelCollapseSeries": "Serijale prikaži sažeto", "LabelCollapseSubSeries": "Podserijale prikaži sažeto", "LabelCollection": "Zbirka", - "LabelCollections": "Zbirka/i", + "LabelCollections": "Zbirke", "LabelComplete": "Dovršeno", "LabelConfirmPassword": "Potvrda zaporke", "LabelContinueListening": "Nastavi slušati", @@ -358,6 +358,7 @@ "LabelFontScale": "Veličina slova", "LabelFontStrikethrough": "Precrtano", "LabelFormat": "Format", + "LabelFull": "Cijeli", "LabelGenre": "Žanr", "LabelGenres": "Žanrovi", "LabelHardDeleteFile": "Obriši datoteku zauvijek", @@ -491,8 +492,8 @@ "LabelPubDate": "Datum izdavanja", "LabelPublishYear": "Godina objavljivanja", "LabelPublishedDate": "Objavljeno {0}", - "LabelPublishedDecade": "Desetljeće objavljivanja", - "LabelPublishedDecades": "Desetljeća objavljivanja", + "LabelPublishedDecade": "Desetljeće izdanja", + "LabelPublishedDecades": "Desetljeća izdanja", "LabelPublisher": "Izdavač", "LabelPublishers": "Izdavači", "LabelRSSFeedCustomOwnerEmail": "Prilagođena adresa e-pošte vlasnika", @@ -530,7 +531,7 @@ "LabelSendEbookToDevice": "Pošalji e-knjigu", "LabelSequence": "Slijed", "LabelSerial": "Serijal", - "LabelSeries": "Serijal/a", + "LabelSeries": "Serijal", "LabelSeriesName": "Ime serijala", "LabelSeriesProgress": "Napredak u serijalu", "LabelServerLogLevel": "Razina zapisa poslužitelja", @@ -639,7 +640,7 @@ "LabelTotalTimeListened": "Sveukupno vrijeme slušanja", "LabelTrackFromFilename": "Naslov iz imena datoteke", "LabelTrackFromMetadata": "Naslov iz meta-podataka", - "LabelTracks": "Naslovi", + "LabelTracks": "Zvučni zapisi", "LabelTracksMultiTrack": "Više zvučnih zapisa", "LabelTracksNone": "Nema zapisa", "LabelTracksSingleTrack": "Jedan zvučni zapis", @@ -740,7 +741,7 @@ "MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjiga/u \"{1}\" na uređaj \"{2}\"?", "MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?", "MessageDownloadingEpisode": "Preuzimam nastavak", - "MessageDragFilesIntoTrackOrder": "Ispravi redoslijed zapisa prevlačenje datoteka", + "MessageDragFilesIntoTrackOrder": "Prevlačenjem datoteka složite pravilan redoslijed", "MessageEmbedFailed": "Ugrađivanje nije uspjelo!", "MessageEmbedFinished": "Ugrađivanje je dovršeno!", "MessageEmbedQueue": "Ugrađivanje meta-podataka dodano u red obrade ({0} u redu)", From 2243fdddd34103a90fc802bab3f1ad4336870daf Mon Sep 17 00:00:00 2001 From: Plazec Date: Mon, 21 Oct 2024 10:31:49 +0000 Subject: [PATCH 293/539] Translated using Weblate (Czech) Currently translated at 82.8% (882 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index 9b429f5e66..3f028f574f 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Vybrat soubory", "ButtonClearFilter": "Vymazat filtr", "ButtonCloseFeed": "Zavřít kanál", + "ButtonCloseSession": "Zavřít otevřenou relaci", "ButtonCollections": "Kolekce", "ButtonConfigureScanner": "Konfigurovat Prohledávání", "ButtonCreate": "Vytvořit", @@ -175,6 +176,7 @@ "HeaderRemoveEpisodes": "Odstranit {0} epizody", "HeaderSavedMediaProgress": "Průběh uložených médií", "HeaderSchedule": "Plán", + "HeaderScheduleEpisodeDownloads": "Naplánovat automatické stahování epizod", "HeaderScheduleLibraryScans": "Naplánovat automatické prohledávání knihoven", "HeaderSession": "Relace", "HeaderSetBackupSchedule": "Nastavit plán zálohování", From 4e90f90c283e12f9cafe58d400f517d3746f7917 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Mon, 21 Oct 2024 10:19:16 +0000 Subject: [PATCH 294/539] Translated using Weblate (Spanish) Currently translated at 97.0% (1033 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index d9410d7a4b..dbd8bbc6ec 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -251,6 +251,7 @@ "LabelBackupsNumberToKeep": "Numero de respaldos para conservar", "LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.", "LabelBitrate": "Tasa de bits", + "LabelBonus": "Bonus", "LabelBooks": "Libros", "LabelButtonText": "Texto del botón", "LabelByAuthor": "por {0}", @@ -329,6 +330,7 @@ "LabelEpisodeType": "Tipo de Episodio", "LabelEpisodeUrlFromRssFeed": "URL del episodio del feed RSS", "LabelEpisodes": "Episodios", + "LabelEpisodic": "Episodios", "LabelExample": "Ejemplo", "LabelExpandSeries": "Ampliar serie", "LabelExpandSubSeries": "Expandir la subserie", From 28d93d916093fe3aa83fd8a276d28fc25aba4ab7 Mon Sep 17 00:00:00 2001 From: Mathias Franco Date: Mon, 21 Oct 2024 15:26:09 +0000 Subject: [PATCH 295/539] Translated using Weblate (Dutch) Currently translated at 83.0% (884 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 182 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 1 deletion(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index aad7f30156..7a246e3d0d 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -65,6 +65,7 @@ "ButtonQueueAddItem": "In wachtrij zetten", "ButtonQueueRemoveItem": "Uit wachtrij verwijderen", "ButtonQuickEmbed": "Snel Embedden", + "ButtonQuickEmbedMetadata": "Snel Metadata Insluiten", "ButtonQuickMatch": "Snelle match", "ButtonReScan": "Nieuwe scan", "ButtonRead": "Lees", @@ -97,6 +98,8 @@ "ButtonStats": "Statistieken", "ButtonSubmit": "Indienen", "ButtonTest": "Testen", + "ButtonUnlinkOpenId": "OpenID Ontkoppelen", + "ButtonUpload": "Upload", "ButtonUploadBackup": "Upload back-up", "ButtonUploadCover": "Upload cover", "ButtonUploadOPMLFile": "Upload OPML-bestand", @@ -108,10 +111,12 @@ "ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten", "ErrorUploadLacksTitle": "Moet een titel hebben", "HeaderAccount": "Account", + "HeaderAddCustomMetadataProvider": "Aangepaste Metadataprovider Toevoegen", "HeaderAdvanced": "Geavanceerd", "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", "HeaderAudioTracks": "Audiotracks", "HeaderAudiobookTools": "Audioboekbestandbeheer tools", + "HeaderAuthentication": "Authenticatie", "HeaderBackups": "Back-ups", "HeaderChangePassword": "Wachtwoord wijzigen", "HeaderChapters": "Hoofdstukken", @@ -120,6 +125,8 @@ "HeaderCollectionItems": "Collectie-objecten", "HeaderCover": "Omslag", "HeaderCurrentDownloads": "Huidige downloads", + "HeaderCustomMessageOnLogin": "Aangepast Bericht bij Aanmelden", + "HeaderCustomMetadataProviders": "Aangepaste Metadata Providers", "HeaderDetails": "Details", "HeaderDownloadQueue": "Download-wachtrij", "HeaderEbookFiles": "Ebook bestanden", @@ -140,16 +147,26 @@ "HeaderLibraryStats": "Bibliotheekstatistieken", "HeaderListeningSessions": "Luistersessies", "HeaderListeningStats": "Luisterstatistieken", + "HeaderLogin": "Aanmelden", + "HeaderLogs": "Logboek", "HeaderManageGenres": "Genres beheren", "HeaderManageTags": "Tags beheren", + "HeaderMapDetails": "Map details", + "HeaderMatch": "Vergelijken", + "HeaderMetadataOrderOfPrecedence": "Metadata volgorde", "HeaderMetadataToEmbed": "In te sluiten metadata", "HeaderNewAccount": "Nieuwe account", "HeaderNewLibrary": "Nieuwe bibliotheek", + "HeaderNotificationCreate": "Notificatie Aanmaken", + "HeaderNotificationUpdate": "Update Notificatie", "HeaderNotifications": "Notificaties", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authenticatie", "HeaderOpenRSSFeed": "Open RSS-feed", "HeaderOtherFiles": "Andere bestanden", + "HeaderPasswordAuthentication": "Wachtwoord Authenticatie", "HeaderPermissions": "Toestemmingen", "HeaderPlayerQueue": "Afspeelwachtrij", + "HeaderPlayerSettings": "Speler Instellingen", "HeaderPlaylist": "Afspeellijst", "HeaderPlaylistItems": "Onderdelen in afspeellijst", "HeaderPodcastsToAdd": "Toe te voegen podcasts", @@ -161,6 +178,7 @@ "HeaderRemoveEpisodes": "Verwijder {0} afleveringen", "HeaderSavedMediaProgress": "Opgeslagen mediavoortgang", "HeaderSchedule": "Schema", + "HeaderScheduleEpisodeDownloads": "Automatische afleveringsdownloads plannen", "HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans", "HeaderSession": "Sessie", "HeaderSetBackupSchedule": "Kies schema voor back-up", @@ -168,6 +186,7 @@ "HeaderSettingsDisplay": "Toon", "HeaderSettingsExperimental": "Experimentele functies", "HeaderSettingsGeneral": "Algemeen", + "HeaderSettingsScanner": "Scanner", "HeaderSleepTimer": "Slaaptimer", "HeaderStatsLargestItems": "Grootste items", "HeaderStatsLongestItems": "Langste items (uren)", @@ -176,13 +195,18 @@ "HeaderStatsTop10Authors": "Top 10 auteurs", "HeaderStatsTop5Genres": "Top 5 genres", "HeaderTableOfContents": "Inhoudsopgave", + "HeaderTools": "Gereedschap", "HeaderUpdateAccount": "Account bijwerken", "HeaderUpdateAuthor": "Auteur bijwerken", "HeaderUpdateDetails": "Details bijwerken", "HeaderUpdateLibrary": "Bibliotheek bijwerken", "HeaderUsers": "Gebruikers", + "HeaderYearReview": "Jaar {0} in Review", "HeaderYourStats": "Je statistieken", "LabelAbridged": "Verkort", + "LabelAbridgedChecked": "Verkort (gechecked)", + "LabelAbridgedUnchecked": "Onverkort (niet gechecked)", + "LabelAccessibleBy": "Toegankelijk door", "LabelAccountType": "Accounttype", "LabelAccountTypeAdmin": "Beheerder", "LabelAccountTypeGuest": "Gast", @@ -193,32 +217,54 @@ "LabelAddToPlaylist": "Toevoegen aan afspeellijst", "LabelAddToPlaylistBatch": "{0} onderdelen toevoegen aan afspeellijst", "LabelAddedAt": "Toegevoegd op", + "LabelAddedDate": "Toegevoegd {0}", + "LabelAdminUsersOnly": "Enkel Admin gebruikers", "LabelAll": "Alle", "LabelAllUsers": "Alle gebruikers", + "LabelAllUsersExcludingGuests": "Alle gebruikers exclusief gasten", + "LabelAllUsersIncludingGuests": "Alle gebruikers inclusief gasten", "LabelAlreadyInYourLibrary": "Reeds in je bibliotheek", "LabelAppend": "Achteraan toevoegen", + "LabelAudioBitrate": "Audio Bitrate (b.v. 128k)", + "LabelAudioChannels": "Audio Kanalen (1 of 2)", + "LabelAudioCodec": "Audio Codec", "LabelAuthor": "Auteur", "LabelAuthorFirstLast": "Auteur (Voornaam Achternaam)", "LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)", "LabelAuthors": "Auteurs", "LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden", + "LabelAutoFetchMetadata": "Automatisch Metadata Ophalen", + "LabelAutoFetchMetadataHelp": "Haalt metadata op voor titel, auteur en serie om het uploaden te stroomlijnen. Aanvullende metadata moet mogelijk worden gematcht na het uploaden.", + "LabelAutoLaunch": "Automatisch Openen", + "LabelAutoLaunchDescription": "Automatisch doorverwijzen naar de auth-provider bij het navigeren naar de inlogpagina (handmatig pad /login?autoLaunch=0)", + "LabelAutoRegister": "Automatisch Registreren", + "LabelAutoRegisterDescription": "Automatisch nieuwe gebruikers aanmaken na inloggen", "LabelBackToUser": "Terug naar gebruiker", + "LabelBackupAudioFiles": "Back-up audiobestanden", "LabelBackupLocation": "Back-up locatie", "LabelBackupsEnableAutomaticBackups": "Automatische back-ups inschakelen", "LabelBackupsEnableAutomaticBackupsHelp": "Back-ups opgeslagen in /metadata/backups", - "LabelBackupsMaxBackupSize": "Maximale back-up-grootte (in GB)", + "LabelBackupsMaxBackupSize": "Maximale back-up-grootte (in GB) (0 voor ongelimiteerd)", "LabelBackupsMaxBackupSizeHelp": "Als een beveiliging tegen verkeerde instelling, zullen back-up mislukken als ze de ingestelde grootte overschrijden.", "LabelBackupsNumberToKeep": "Aantal te bewaren back-ups", "LabelBackupsNumberToKeepHelp": "Er wordt slechts 1 back-up per keer verwijderd, dus als je reeds meer back-ups dan dit hebt moet je ze handmatig verwijderen.", + "LabelBitrate": "Bitrate", + "LabelBonus": "Bonus", "LabelBooks": "Boeken", + "LabelButtonText": "Knop Tekst", + "LabelByAuthor": "Door {0}", "LabelChangePassword": "Wachtwoord wijzigen", "LabelChannels": "Kanalen", + "LabelChapterCount": "{0} Hoofdstukken", "LabelChapterTitle": "Hoofdstuktitel", "LabelChapters": "Hoofdstukken", "LabelChaptersFound": "Hoofdstukken gevonden", "LabelClickForMoreInfo": "Klik voor meer informatie", + "LabelClickToUseCurrentValue": "Klik om huidige waarde te gebruiken", "LabelClosePlayer": "Sluit speler", + "LabelCodec": "Codec", "LabelCollapseSeries": "Series inklappen", + "LabelCollapseSubSeries": "Subserie samenvouwen", "LabelCollection": "Collectie", "LabelCollections": "Collecties", "LabelComplete": "Compleet", @@ -226,6 +272,7 @@ "LabelContinueListening": "Verder Luisteren", "LabelContinueReading": "Verder lezen", "LabelContinueSeries": "Doorgaan met Serie", + "LabelCover": "Omslag", "LabelCoverImageURL": "Coverafbeelding URL", "LabelCreatedAt": "Gecreëerd op", "LabelCronExpression": "Cron-uitdrukking", @@ -234,38 +281,68 @@ "LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:", "LabelDatetime": "Datum-tijd", "LabelDays": "Dagen", + "LabelDeleteFromFileSystemCheckbox": "Verwijderen uit bestandssysteem (uncheck om alleen uit database te verwijderen)", "LabelDescription": "Beschrijving", "LabelDeselectAll": "Deselecteer alle", "LabelDevice": "Apparaat", "LabelDeviceInfo": "Apparaat info", + "LabelDeviceIsAvailableTo": "Apparaat is beschikbaar voor...", "LabelDirectory": "Map", "LabelDiscFromFilename": "Schijf uit bestandsnaam", "LabelDiscFromMetadata": "Schijf uit metadata", "LabelDiscover": "Ontdekken", "LabelDownload": "Download", + "LabelDownloadNEpisodes": "Download {0} afleveringen", "LabelDuration": "Duur", + "LabelDurationComparisonExactMatch": "(exacte overeenkomst)", + "LabelDurationComparisonLonger": "({0} langer)", + "LabelDurationComparisonShorter": "({0} korter)", "LabelDurationFound": "Gevonden duur:", "LabelEbook": "Ebook", "LabelEbooks": "Eboeken", "LabelEdit": "Wijzig", + "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Van-adres", + "LabelEmailSettingsRejectUnauthorized": "Ongeautoriseerde certificaten afwijzen", + "LabelEmailSettingsRejectUnauthorizedHelp": "Het uitschakelen van SSL-certificaatvalidatie kan uw verbinding blootstellen aan beveiligingsrisico's, zoals man-in-the-middle-aanvallen. Schakel deze optie alleen uit als u de implicaties begrijpt en de mailserver waarmee u verbinding maakt vertrouwt.", "LabelEmailSettingsSecure": "Veilig", "LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test-adres", "LabelEmbeddedCover": "Ingesloten cover", "LabelEnable": "Inschakelen", + "LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:", + "LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.", + "LabelEncodingClearItemCache": "Zorg ervoor dat u de cache van items regelmatig wist.", + "LabelEncodingFinishedM4B": "Een voltooide M4B wordt in uw audioboekfolder geplaatst in:", + "LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.", + "LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.", + "LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.", + "LabelEncodingWarningAdvancedSettings": "Waarschuwing: update deze instellingen niet tenzij u bekend bent met de coderingsopties van ffmpeg.", + "LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.", "LabelEnd": "Einde", "LabelEndOfChapter": "Einde van het Hoofdstuk", "LabelEpisode": "Aflevering", + "LabelEpisodeNotLinkedToRssFeed": "Aflevering niet gelinkt aan RSS feed", + "LabelEpisodeNumber": "Aflevering #{0}", "LabelEpisodeTitle": "Afleveringtitel", "LabelEpisodeType": "Afleveringtype", + "LabelEpisodeUrlFromRssFeed": "Aflevering URL van RSS feed", + "LabelEpisodes": "Afleveringen", + "LabelEpisodic": "Episodisch", "LabelExample": "Voorbeeld", + "LabelExpandSeries": "Serie Uitvouwen", + "LabelExpandSubSeries": "Subserie Uitvouwen", "LabelExplicit": "Expliciet", + "LabelExplicitChecked": "Expliciet (gechecked)", + "LabelExplicitUnchecked": "Niet Expliciet (niet gechecked)", + "LabelExportOPML": "OPML exporteren", "LabelFeedURL": "Feed URL", "LabelFetchingMetadata": "Metadata ophalen", "LabelFile": "Bestand", "LabelFileBirthtime": "Aanmaaktijd bestand", + "LabelFileBornDate": "Geboren {0}", "LabelFileModified": "Bestand gewijzigd", + "LabelFileModifiedDate": "Gewijzigd {0}", "LabelFilename": "Bestandsnaam", "LabelFilterByUser": "Filter op gebruiker", "LabelFindEpisodes": "Zoek afleveringen", @@ -275,20 +352,27 @@ "LabelFontBold": "Vetgedrukt", "LabelFontBoldness": "Font Boldness", "LabelFontFamily": "Lettertypefamilie", + "LabelFontItalic": "Cursief", "LabelFontScale": "Lettertype schaal", + "LabelFontStrikethrough": "Doorgestreept", "LabelFormat": "Formaat", + "LabelFull": "Vol", "LabelGenre": "Genre", "LabelGenres": "Genres", "LabelHardDeleteFile": "Hard-delete bestand", "LabelHasEbook": "Heeft Ebook", "LabelHasSupplementaryEbook": "Heeft aanvullend Ebook", + "LabelHideSubtitles": "Ondertitels Verstoppen", + "LabelHighestPriority": "Hoogste Prioriteit", "LabelHost": "Host", "LabelHour": "Uur", "LabelHours": "Uren", "LabelIcon": "Icoon", + "LabelImageURLFromTheWeb": "Afbeelding URL van web", "LabelInProgress": "Bezig", "LabelIncludeInTracklist": "Includeer in tracklijst", "LabelIncomplete": "Incompleet", + "LabelInterval": "Interval", "LabelIntervalCustomDailyWeekly": "Aangepast dagelijks/wekelijks", "LabelIntervalEvery12Hours": "Iedere 12 uur", "LabelIntervalEvery15Minutes": "Iedere 15 minuten", @@ -299,8 +383,11 @@ "LabelIntervalEveryHour": "Ieder uur", "LabelInvert": "Omdraaien", "LabelItem": "Onderdeel", + "LabelJumpBackwardAmount": "Terugspoelen hoeveelheid", + "LabelJumpForwardAmount": "Vooruitspoelen hoeveelheid", "LabelLanguage": "Taal", "LabelLanguageDefaultServer": "Standaard servertaal", + "LabelLanguages": "Talen", "LabelLastBookAdded": "Laatst toegevoegde boek", "LabelLastBookUpdated": "Laatst bijgewerkte boek", "LabelLastSeen": "Laatst gezien", @@ -312,20 +399,36 @@ "LabelLess": "Minder", "LabelLibrariesAccessibleToUser": "Voor gebruiker toegankelijke bibliotheken", "LabelLibrary": "Bibliotheek", + "LabelLibraryFilterSublistEmpty": "Nee {0}", "LabelLibraryItem": "Bibliotheekonderdeel", "LabelLibraryName": "Bibliotheeknaam", "LabelLimit": "Limiet", "LabelLineSpacing": "Regelruimte", "LabelListenAgain": "Opnieuw Beluisteren", + "LabelLogLevelDebug": "Debug", + "LabelLogLevelInfo": "Informatie", "LabelLogLevelWarn": "Waarschuwing", "LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum", + "LabelLowestPriority": "Laagste Prioriteit", + "LabelMatchExistingUsersBy": "Bestaande gebruikers matchen op", + "LabelMatchExistingUsersByDescription": "Wordt gebruikt om bestaande gebruikers te verbinden. Zodra ze verbonden zijn, worden gebruikers gekoppeld aan een unieke id van uw SSO-provider.", + "LabelMaxEpisodesToDownload": "Maximale # afleveringen om te downloaden. Gebruik 0 voor ongelimiteerd.", + "LabelMaxEpisodesToDownloadPerCheck": "Maximale # nieuwe afleveringen om te downloaden per check", + "LabelMaxEpisodesToKeep": "Maximale # afleveringen om te houden", + "LabelMaxEpisodesToKeepHelp": "Waarde van 0 stelt geen maximumlimiet in. Nadat een nieuwe aflevering automatisch is gedownload, wordt de oudste aflevering verwijderd als u meer dan X afleveringen hebt. Hiermee wordt slechts 1 aflevering per nieuwe download verwijderd.", "LabelMediaPlayer": "Mediaspeler", "LabelMediaType": "Mediatype", "LabelMetaTag": "Meta-tag", "LabelMetaTags": "Meta-tags", + "LabelMetadataOrderOfPrecedenceDescription": "Metadatabronnen met een hogere prioriteit zullen metadatabronnen met een lagere prioriteit overschrijven", "LabelMetadataProvider": "Metadatabron", "LabelMinute": "Minuut", + "LabelMinutes": "Minuten", "LabelMissing": "Ontbrekend", + "LabelMissingEbook": "Heeft geen ebook", + "LabelMissingSupplementaryEbook": "Heeft geen supplementair ebook", + "LabelMobileRedirectURIs": "Toegestane mobiele omleidings-URL's", + "LabelMobileRedirectURIsDescription": "Dit is een whitelist met geldige redirect-URI's voor mobiele apps. De standaard is audiobookshelf://oauth, die u kunt verwijderen of aanvullen met extra URI's voor integratie met apps van derden. Als u een asterisk (*) als enige invoer gebruikt, is elke URI toegestaan.", "LabelMore": "Meer", "LabelMoreInfo": "Meer info", "LabelName": "Naam", @@ -337,10 +440,12 @@ "LabelNewestEpisodes": "Nieuwste Afleveringen", "LabelNextBackupDate": "Volgende back-up datum", "LabelNextScheduledRun": "Volgende geplande run", + "LabelNoCustomMetadataProviders": "Geen custom metadata bronnen", "LabelNoEpisodesSelected": "Geen afleveringen geselecteerd", "LabelNotFinished": "Niet Voltooid", "LabelNotStarted": "Niet Gestart", "LabelNotes": "Notities", + "LabelNotificationAppriseURL": "URL(s) van kennisgeving", "LabelNotificationAvailableVariables": "Beschikbare variabelen", "LabelNotificationBodyTemplate": "Body-template", "LabelNotificationEvent": "Notificatie gebeurtenis", @@ -351,10 +456,14 @@ "LabelNotificationsMaxQueueSizeHelp": "Gebeurtenissen zijn beperkt tot 1 aftrap per seconde. Gebeurtenissen zullen genegeerd worden als de rij aan de maximale grootte zit. Dit voorkomt notificatie-spamming.", "LabelNumberOfBooks": "Aantal Boeken", "LabelNumberOfEpisodes": "# afleveringen", + "LabelOpenIDAdvancedPermsClaimDescription": "Naam van de OpenID-claim die geavanceerde machtigingen bevat voor gebruikersacties binnen de applicatie die van toepassing zijn op niet-beheerdersrollen (indien geconfigureerd). Als de claim ontbreekt in het antwoord, wordt toegang tot ABS geweigerd. Als er één optie ontbreekt, wordt deze behandeld als false. Zorg ervoor dat de claim van de identiteitsprovider overeenkomt met de verwachte structuur:", + "LabelOpenIDClaims": "Laat de volgende opties leeg om geavanceerde groeps- en machtigingstoewijzing uit te schakelen en de groep 'Gebruiker' automatisch toe te wijzen.", + "LabelOpenIDGroupClaimDescription": "Naam van de OpenID-claim die een lijst met de groepen van de gebruiker bevat. Vaak aangeduid als groepen. Indien geconfigureerd, zal de applicatie automatisch rollen toewijzen op basis van de groepslidmaatschappen van de gebruiker, op voorwaarde dat deze groepen hoofdlettergevoelig 'admin', 'gebruiker' of 'gast' worden genoemd in de claim. De claim moet een lijst bevatten en als een gebruiker tot meerdere groepen behoort, zal de applicatie de rol toewijzen die overeenkomt met het hoogste toegangsniveau. Als er geen groep overeenkomt, wordt de toegang geweigerd.", "LabelOpenRSSFeed": "Open RSS-feed", "LabelOverwrite": "Overschrijf", "LabelPassword": "Wachtwoord", "LabelPath": "Pad", + "LabelPermanent": "Permanent", "LabelPermissionsAccessAllLibraries": "Heeft toegang tot all bibliotheken", "LabelPermissionsAccessAllTags": "Heeft toegang tot alle tags", "LabelPermissionsAccessExplicitContent": "Heeft toegang tot expliciete inhoud", @@ -362,21 +471,29 @@ "LabelPermissionsDownload": "Kan downloaden", "LabelPermissionsUpdate": "Kan bijwerken", "LabelPermissionsUpload": "Kan uploaden", + "LabelPersonalYearReview": "Jouw jaar in review ({0})", "LabelPhotoPathURL": "Foto pad/URL", "LabelPlayMethod": "Afspeelwijze", + "LabelPlayerChapterNumberMarker": "{0} van {1}", "LabelPlaylists": "Afspeellijsten", "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Podcast zoekregio", "LabelPodcastType": "Podcasttype", + "LabelPodcasts": "Podcasts", "LabelPort": "Poort", "LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)", "LabelPreventIndexing": "Voorkom indexering van je feed door iTunes- en Google podcastmappen", "LabelPrimaryEbook": "Primair ebook", "LabelProgress": "Voortgang", "LabelProvider": "Bron", + "LabelProviderAuthorizationValue": "Autorisatie Header Waarde", "LabelPubDate": "Publicatiedatum", "LabelPublishYear": "Jaar van uitgave", + "LabelPublishedDate": "Gepubliceerd {0}", + "LabelPublishedDecade": "Gepubliceerd Decennium", + "LabelPublishedDecades": "Gepubliceerd Decennia", "LabelPublisher": "Uitgever", + "LabelPublishers": "Uitgevers", "LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar", "LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar", "LabelRSSFeedOpen": "RSS-feed open", @@ -384,31 +501,44 @@ "LabelRSSFeedSlug": "RSS-feed slug", "LabelRSSFeedURL": "RSS-feed URL", "LabelRandomly": "Willekeurig", + "LabelReAddSeriesToContinueListening": "Serie opnieuw toevoegen aan verder luisteren", "LabelRead": "Lees", "LabelReadAgain": "Opnieuw Lezen", "LabelReadEbookWithoutProgress": "Lees ebook zonder voortgang bij te houden", "LabelRecentSeries": "Recente Serie", "LabelRecentlyAdded": "Recent Toegevoegd", "LabelRecommended": "Aangeraden", + "LabelRedo": "Opnieuw", "LabelRegion": "Regio", "LabelReleaseDate": "Verschijningsdatum", + "LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden", + "LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden", "LabelRemoveCover": "Verwijder cover", + "LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders", + "LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.", + "LabelRowsPerPage": "Rijen per pagina", "LabelSearchTerm": "Zoekterm", "LabelSearchTitle": "Zoek titel", "LabelSearchTitleOrASIN": "Zoek titel of ASIN", "LabelSeason": "Seizoen", + "LabelSeasonNumber": "Seizoen #{0}", + "LabelSelectAll": "Alles selecteren", "LabelSelectAllEpisodes": "Selecteer alle afleveringen", "LabelSelectEpisodesShowing": "Selecteer {0} afleveringen laten zien", + "LabelSelectUsers": "Selecteer gebruikers", "LabelSendEbookToDevice": "Stuur ebook naar...", "LabelSequence": "Sequentie", "LabelSeries": "Serie", "LabelSeriesName": "Naam serie", "LabelSeriesProgress": "Voortgang serie", + "LabelServerLogLevel": "Server Log Niveau", + "LabelServerYearReview": "Server Jaar in Review ({0})", "LabelSetEbookAsPrimary": "Stel in als primair", "LabelSetEbookAsSupplementary": "Stel in als supplementair", "LabelSettingsAudiobooksOnly": "Alleen audiobooks", "LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", + "LabelSettingsChromecastSupport": "Chromecast ondersteuning", "LabelSettingsDateFormat": "Datum format", "LabelSettingsDisableWatcher": "Watcher uitschakelen", "LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen", @@ -416,6 +546,8 @@ "LabelSettingsEnableWatcher": "Watcher inschakelen", "LabelSettingsEnableWatcherForLibrary": "Map-watcher voor bibliotheek inschakelen", "LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server", + "LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs", + "LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.", "LabelSettingsExperimentalFeatures": "Experimentele functies", "LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.", "LabelSettingsFindCovers": "Zoek covers", @@ -424,6 +556,8 @@ "LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.", "LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina", "LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.", "LabelSettingsParseSubtitles": "Parseer subtitel", "LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.
Subtitel moet gescheiden zijn met \" - \"
b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"", "LabelSettingsPreferMatchedMetadata": "Prefereer gematchte metadata", @@ -439,9 +573,15 @@ "LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel", "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden", "LabelSettingsTimeFormat": "Tijdformat", + "LabelShare": "Delen", + "LabelShareOpen": "Delen Open", + "LabelShareURL": "URL Delen", "LabelShowAll": "Toon alle", + "LabelShowSeconds": "Laat seconden zien", + "LabelShowSubtitles": "Laat Ondertitels zien", "LabelSize": "Grootte", "LabelSleepTimer": "Slaaptimer", + "LabelSlug": "Slak", "LabelStart": "Start", "LabelStartTime": "Starttijd", "LabelStarted": "Gestart", @@ -468,10 +608,19 @@ "LabelTagsAccessibleToUser": "Tags toegankelijk voor de gebruiker", "LabelTagsNotAccessibleToUser": "Tags niet toegankelijk voor de gebruiker", "LabelTasks": "Lopende taken", + "LabelTextEditorBulletedList": "Opgesomde lijst", + "LabelTextEditorLink": "Link", + "LabelTextEditorNumberedList": "Genummerde lijst", + "LabelTextEditorUnlink": "Unlink", "LabelTheme": "Thema", "LabelThemeDark": "Donker", "LabelThemeLight": "Licht", "LabelTimeBase": "Tijdsbasis", + "LabelTimeDurationXHours": "{0} Uren", + "LabelTimeDurationXMinutes": "{0} minuten", + "LabelTimeDurationXSeconds": "{0} seconden", + "LabelTimeInMinutes": "Tijd in minuten", + "LabelTimeLeft": "{0} over", "LabelTimeListened": "Tijd geluisterd", "LabelTimeListenedToday": "Tijd geluisterd vandaag", "LabelTimeRemaining": "{0} te gaan", @@ -479,6 +628,7 @@ "LabelTitle": "Titel", "LabelToolsEmbedMetadata": "Metadata insluiten", "LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.", + "LabelToolsM4bEncoder": "M4B Encoder", "LabelToolsMakeM4b": "Maak M4B-audioboekbestand", "LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.", "LabelToolsSplitM4b": "Splitst M4B in MP3's", @@ -488,12 +638,15 @@ "LabelTrackFromFilename": "Track vanuit bestandsnaam", "LabelTrackFromMetadata": "Track vanuit metadata", "LabelTracks": "Audiosporen", + "LabelTracksMultiTrack": "Multi-spoor", "LabelTracksNone": "Geen tracks", "LabelTracksSingleTrack": "Enkele track", + "LabelTrailer": "Trailer", "LabelType": "Type", "LabelUnabridged": "Onverkort", "LabelUndo": "Ongedaan maken", "LabelUnknown": "Onbekend", + "LabelUnknownPublishDate": "Onbekende uitgeefdatum", "LabelUpdateCover": "Cover bijwerken", "LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetails": "Details bijwerken", @@ -501,16 +654,25 @@ "LabelUpdatedAt": "Bijgewerkt op", "LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen", "LabelUploaderDropFiles": "Bestanden neerzetten", + "LabelUploaderItemFetchMetadataHelp": "Automatisch titel, auteur en serie ophalen", + "LabelUseAdvancedOptions": "Gebruik Geavanceerde Instellingen", "LabelUseChapterTrack": "Gebruik hoofdstuktrack", "LabelUseFullTrack": "Gebruik volledige track", + "LabelUseZeroForUnlimited": "Gebruik 0 voor ongelimiteerd", "LabelUser": "Gebruiker", "LabelUsername": "Gebruikersnaam", "LabelValue": "Waarde", "LabelVersion": "Versie", "LabelViewBookmarks": "Bekijk boekwijzers", "LabelViewChapters": "Bekijk hoofdstukken", + "LabelViewPlayerSettings": "Laat spelerinstellingen zien", "LabelViewQueue": "Bekijk afspeelwachtrij", + "LabelVolume": "Volume", "LabelWeekdaysToRun": "Weekdagen om te draaien", + "LabelXBooks": "{0} boeken", + "LabelXItems": "{0} items", + "LabelYearReviewHide": "Verberg Jaar in Review", + "LabelYearReviewShow": "Laat Jaar in Review zien", "LabelYourAudiobookDuration": "Je audioboekduur", "LabelYourBookmarks": "Je boekwijzers", "LabelYourPlaylists": "Je afspeellijsten", @@ -518,10 +680,14 @@ "MessageAddToPlayerQueue": "Toevoegen aan wachtrij", "MessageAppriseDescription": "Om deze functie te gebruiken heb je een draaiende instantie van Apprise API nodig of een api die dezelfde requests afhandelt.
De Apprise API Url moet het volledige URL-pad zijn om de notificatie te verzenden, b.v., als je API-instantie draait op http://192.168.1.1:8337 dan zou je http://192.168.1.1:8337/notify gebruiken.", "MessageBackupsDescription": "Back-ups omvatten gebruikers, gebruikers' voortgang, bibliotheekonderdeeldetails, serverinstellingen en afbeeldingen bewaard in /metadata/items & /metadata/authors. Back-ups bevatten niet de bestanden bewaard in je bibliotheekmappen.", + "MessageBackupsLocationEditNote": "Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen", + "MessageBackupsLocationNoEditNote": "Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.", + "MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn", "MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", "MessageBookshelfNoResultsForFilter": "Geen resultaten voor filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Geen resultaten voor query", "MessageBookshelfNoSeries": "Je hebt geen series", "MessageChapterEndIsAfter": "Hoofdstukeinde is na het einde van je audioboek", "MessageChapterErrorFirstNotZero": "Eerste hoofdstuk moet starten op 0", @@ -529,15 +695,29 @@ "MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk", "MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek", "MessageCheckingCron": "Cron aan het checken...", + "MessageConfirmCloseFeed": "Ben je zeker dat je deze feed wil sluiten?", "MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?", + "MessageConfirmDeleteDevice": "Ben je zeker dat je e-reader apparaat \"{0}\" wil verwijderen?", "MessageConfirmDeleteFile": "Dit verwijdert het bestand uit het bestandssysteem. Weet je het zeker?", "MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?", + "MessageConfirmDeleteLibraryItem": "Hiermee wordt het bibliotheekitem uit de database en uw bestandssysteem verwijderd. Bent u zeker?", + "MessageConfirmDeleteLibraryItems": "Hiermee worden {0} bibliotheekitems uit de database en uw bestandssysteem verwijderd. Bent u zeker?", + "MessageConfirmDeleteMetadataProvider": "Weet u zeker dat u de aangepaste metadataprovider \"{0}\" wilt verwijderen?", + "MessageConfirmDeleteNotification": "Weet u zeker dat u deze melding wil verwijderen?", "MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?", + "MessageConfirmEmbedMetadataInAudioFiles": "Weet u zeker dat u metagegevens wilt insluiten in {0} audiobestanden?", "MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?", "MessageConfirmMarkAllEpisodesFinished": "Weet je zeker dat je alle afleveringen als voltooid wil markeren?", "MessageConfirmMarkAllEpisodesNotFinished": "Weet je zeker dat je alle afleveringen als niet-voltooid wil markeren?", + "MessageConfirmMarkItemFinished": "Weet u zeker dat u \"{0}\" als voltooid wilt markeren?", + "MessageConfirmMarkItemNotFinished": "Weet u zeker dat u \"{0}\" als niet voltooid wilt markeren?", "MessageConfirmMarkSeriesFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als voltooid?", "MessageConfirmMarkSeriesNotFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als niet voltooid?", + "MessageConfirmNotificationTestTrigger": "Trigger deze melding met test data?", + "MessageConfirmPurgeCache": "Met Purge cache wordt de gehele directory op /metadata/cache verwijderd.

Weet u zeker dat u de cachedirectory wilt verwijderen?", + "MessageConfirmPurgeItemsCache": "Met Purge items cache wordt de gehele directory op /metadata/cache/items verwijderd.
Weet u het zeker?", + "MessageConfirmQuickEmbed": "Waarschuwing! Quick embed maakt geen back-up van uw audiobestanden. Zorg ervoor dat u een back-up van uw audiobestanden hebt.

Wilt u doorgaan?", + "MessageConfirmQuickMatchEpisodes": "Snel matchende afleveringen overschrijven details als er een match is gevonden. Alleen niet-matchende afleveringen worden bijgewerkt. Weet u het zeker?", "MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?", "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", From a7a2fbbca8c4c1cef6e096af044e41d885f91f10 Mon Sep 17 00:00:00 2001 From: Plazec Date: Tue, 22 Oct 2024 08:22:27 +0000 Subject: [PATCH 296/539] Translated using Weblate (Czech) Currently translated at 83.3% (887 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index 3f028f574f..f1c53efc5d 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -30,6 +30,8 @@ "ButtonEditChapters": "Upravit kapitoly", "ButtonEditPodcast": "Upravit podcast", "ButtonEnable": "Povolit", + "ButtonFireAndFail": "Spustit a selhat", + "ButtonFireOnTest": "Spustit událost onTest", "ButtonForceReScan": "Vynutit opětovné prohledání", "ButtonFullPath": "Úplná cesta", "ButtonHide": "Skrýt", @@ -59,10 +61,12 @@ "ButtonPlaylists": "Seznamy skladeb", "ButtonPrevious": "Předchozí", "ButtonPreviousChapter": "Předchozí Kapitola", + "ButtonProbeAudioFile": "Prozkoumat audio soubor", "ButtonPurgeAllCache": "Vyčistit veškerou mezipaměť", "ButtonPurgeItemsCache": "Vyčistit mezipaměť položek", "ButtonQueueAddItem": "Přidat do fronty", "ButtonQueueRemoveItem": "Odstranit z fronty", + "ButtonQuickEmbed": "Rychle Zapsat", "ButtonQuickEmbedMetadata": "Rychle Zapsat Metadata", "ButtonQuickMatch": "Rychlé přiřazení", "ButtonReScan": "Znovu prohledat", @@ -223,6 +227,7 @@ "LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů", "LabelAlreadyInYourLibrary": "Již ve vaší knihovně", "LabelAppend": "Připojit", + "LabelAudioBitrate": "Bitový tok zvuku (např. 128k)", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (jméno a příjmení)", "LabelAuthorLastFirst": "Autor (příjmení a jméno)", From e1caf13233b8847cd71c05cfef2029c1defc956a Mon Sep 17 00:00:00 2001 From: Mathias Franco Date: Tue, 22 Oct 2024 11:46:57 +0000 Subject: [PATCH 297/539] Translated using Weblate (Dutch) Currently translated at 83.7% (891 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/strings/nl.json b/client/strings/nl.json index 7a246e3d0d..0fb0b5fa48 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -528,6 +528,7 @@ "LabelSelectUsers": "Selecteer gebruikers", "LabelSendEbookToDevice": "Stuur ebook naar...", "LabelSequence": "Sequentie", + "LabelSerial": "Serie", "LabelSeries": "Serie", "LabelSeriesName": "Naam serie", "LabelSeriesProgress": "Voortgang serie", @@ -718,12 +719,14 @@ "MessageConfirmPurgeItemsCache": "Met Purge items cache wordt de gehele directory op /metadata/cache/items verwijderd.
Weet u het zeker?", "MessageConfirmQuickEmbed": "Waarschuwing! Quick embed maakt geen back-up van uw audiobestanden. Zorg ervoor dat u een back-up van uw audiobestanden hebt.

Wilt u doorgaan?", "MessageConfirmQuickMatchEpisodes": "Snel matchende afleveringen overschrijven details als er een match is gevonden. Alleen niet-matchende afleveringen worden bijgewerkt. Weet u het zeker?", + "MessageConfirmReScanLibraryItems": "Bent u zeker dat u {0} items opnieuw wil scannen?", "MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?", "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?", "MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?", + "MessageConfirmRemoveMetadataFiles": "Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?", "MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?", "MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?", "MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?", @@ -732,9 +735,12 @@ "MessageConfirmRenameTag": "Weet je zeker dat je tag \"{0}\" wil hernoemen naar\"{1}\" voor alle onderdelen?", "MessageConfirmRenameTagMergeNote": "Opmerking: Deze tag bestaat al, dus zullen ze worden samengevoegd.", "MessageConfirmRenameTagWarning": "Waarschuwing! Een gelijknamige tag met ander hoofdlettergebruik bestaat al: \"{0}\".", + "MessageConfirmResetProgress": "Bet u zeker dat u uw voortgang wil resetten?", "MessageConfirmSendEbookToDevice": "Weet je zeker dat je {0} ebook \"{1}\" naar apparaat \"{2}\" wil sturen?", + "MessageConfirmUnlinkOpenId": "Bent u zeker dat u deze gebruiker wil ontkoppelen van OpenID?", "MessageDownloadingEpisode": "Aflevering aan het dowloaden", "MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde", + "MessageEmbedFailed": "Insluiten Mislukt!", "MessageEmbedFinished": "Insluiting voltooid!", "MessageEpisodesQueuedForDownload": "{0} aflevering(en) in de rij om te downloaden", "MessageFeedURLWillBe": "Feed URL zal {0} zijn", From e76c4ed2a450cf09bf61bf350709b35da3b31ce2 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Tue, 22 Oct 2024 04:58:53 +0000 Subject: [PATCH 298/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1064 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 66d5bf37e8..dadb787b03 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -243,7 +243,7 @@ "LabelAutoRegisterDescription": "Po prijavi samodejno ustvari nove uporabnike", "LabelBackToUser": "Nazaj na uporabnika", "LabelBackupAudioFiles": "Varnostno kopiranje zvočnih datotek", - "LabelBackupLocation": "Lokacija rezervne kopije", + "LabelBackupLocation": "Lokacija varnostnih kopij", "LabelBackupsEnableAutomaticBackups": "Omogoči samodejno varnostno kopiranje", "LabelBackupsEnableAutomaticBackupsHelp": "Varnostne kopije shranjene v /metadata/backups", "LabelBackupsMaxBackupSize": "Največja velikost varnostne kopije (v GB) (0 za neomejeno)", @@ -325,7 +325,7 @@ "LabelEndOfChapter": "Konec poglavja", "LabelEpisode": "Epizoda", "LabelEpisodeNotLinkedToRssFeed": "Epizoda ni povezana z virom RSS", - "LabelEpisodeNumber": "Epizoda #{0}", + "LabelEpisodeNumber": "{0}. epizoda", "LabelEpisodeTitle": "Naslov epizode", "LabelEpisodeType": "Tip epizode", "LabelEpisodeUrlFromRssFeed": "URL epizode iz vira RSS", @@ -393,7 +393,7 @@ "LabelLastBookAdded": "Zadnja dodana knjiga", "LabelLastBookUpdated": "Zadnja posodobljena knjiga", "LabelLastSeen": "Nazadnje viden", - "LabelLastTime": "Zadnji čas", + "LabelLastTime": "Nazadnje", "LabelLastUpdate": "Zadnja posodobitev", "LabelLayout": "Postavitev", "LabelLayoutSinglePage": "Ena stran", @@ -457,7 +457,7 @@ "LabelNotificationsMaxQueueSize": "Največja velikost čakalne vrste za dogodke obvestil", "LabelNotificationsMaxQueueSizeHelp": "Dogodki so omejeni na sprožitev 1 na sekundo. Dogodki bodo prezrti, če je čakalna vrsta najvišja. To preprečuje neželeno pošiljanje obvestil.", "LabelNumberOfBooks": "Število knjig", - "LabelNumberOfEpisodes": "# od epizod", + "LabelNumberOfEpisodes": "število epizod", "LabelOpenIDAdvancedPermsClaimDescription": "Ime zahtevka OpenID, ki vsebuje napredna dovoljenja za uporabniška dejanja v aplikaciji, ki bodo veljala za neskrbniške vloge (če je konfigurirano). Če trditev manjka v odgovoru, bo dostop do ABS zavrnjen. Če ena možnost manjka, bo obravnavana kot false. Zagotovite, da se zahtevek ponudnika identitete ujema s pričakovano strukturo:", "LabelOpenIDClaims": "Pustite naslednje možnosti prazne, da onemogočite napredno dodeljevanje skupin in dovoljenj, nato pa samodejno dodelite skupino 'Uporabnik'.", "LabelOpenIDGroupClaimDescription": "Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane skupine. Če je konfigurirana, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.", @@ -586,7 +586,7 @@ "LabelSleepTimer": "Časovnik za spanje", "LabelSlug": "Slug", "LabelStart": "Začetek", - "LabelStartTime": "Začetni čas", + "LabelStartTime": "Čas začetka", "LabelStarted": "Začeto", "LabelStartedAt": "Začeto ob", "LabelStatsAudioTracks": "Zvočni posnetki", From 1fefc1af92db31381434a4a5db830f9ee4cf3740 Mon Sep 17 00:00:00 2001 From: Mathias Franco Date: Wed, 23 Oct 2024 14:03:19 +0000 Subject: [PATCH 299/539] Translated using Weblate (Dutch) Currently translated at 92.8% (991 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 102 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/client/strings/nl.json b/client/strings/nl.json index 0fb0b5fa48..8c9499d73c 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -30,6 +30,8 @@ "ButtonEditChapters": "Hoofdstukken wijzigen", "ButtonEditPodcast": "Podcast wijzigen", "ButtonEnable": "Aanzetten", + "ButtonFireAndFail": "Fire and Fail", + "ButtonFireOnTest": "Fire onTest event", "ButtonForceReScan": "Forceer nieuwe scan", "ButtonFullPath": "Volledig pad", "ButtonHide": "Verberg", @@ -161,6 +163,7 @@ "HeaderNotificationUpdate": "Update Notificatie", "HeaderNotifications": "Notificaties", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authenticatie", + "HeaderOpenListeningSessions": "Open Luistersessies", "HeaderOpenRSSFeed": "Open RSS-feed", "HeaderOtherFiles": "Andere bestanden", "HeaderPasswordAuthentication": "Wachtwoord Authenticatie", @@ -224,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Alle gebruikers exclusief gasten", "LabelAllUsersIncludingGuests": "Alle gebruikers inclusief gasten", "LabelAlreadyInYourLibrary": "Reeds in je bibliotheek", + "LabelApiToken": "API Token", "LabelAppend": "Achteraan toevoegen", "LabelAudioBitrate": "Audio Bitrate (b.v. 128k)", "LabelAudioChannels": "Audio Kanalen (1 of 2)", @@ -461,6 +465,7 @@ "LabelOpenIDGroupClaimDescription": "Naam van de OpenID-claim die een lijst met de groepen van de gebruiker bevat. Vaak aangeduid als groepen. Indien geconfigureerd, zal de applicatie automatisch rollen toewijzen op basis van de groepslidmaatschappen van de gebruiker, op voorwaarde dat deze groepen hoofdlettergevoelig 'admin', 'gebruiker' of 'gast' worden genoemd in de claim. De claim moet een lijst bevatten en als een gebruiker tot meerdere groepen behoort, zal de applicatie de rol toewijzen die overeenkomt met het hoogste toegangsniveau. Als er geen groep overeenkomt, wordt de toegang geweigerd.", "LabelOpenRSSFeed": "Open RSS-feed", "LabelOverwrite": "Overschrijf", + "LabelPaginationPageXOfY": "Pagina {0} van {1}", "LabelPassword": "Wachtwoord", "LabelPath": "Pad", "LabelPermanent": "Permanent", @@ -742,7 +747,9 @@ "MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde", "MessageEmbedFailed": "Insluiten Mislukt!", "MessageEmbedFinished": "Insluiting voltooid!", + "MessageEmbedQueue": "In de wachtrij voor metadata-embed ({0} in wachtrij)", "MessageEpisodesQueuedForDownload": "{0} aflevering(en) in de rij om te downloaden", + "MessageEreaderDevices": "Om de levering van e-books te garanderen, moet u mogelijk bovenstaand e-mailadres opgeven als geldige afzender voor elk hieronder vermeld apparaat.", "MessageFeedURLWillBe": "Feed URL zal {0} zijn", "MessageFetching": "Aan het ophalen...", "MessageForceReScanDescription": "zal alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.", @@ -754,6 +761,7 @@ "MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar", "MessageLoading": "Aan het laden...", "MessageLoadingFolders": "Mappen aan het laden...", + "MessageLogsDescription": "Logs worden opgeslagen in /metadata/logs als JSON-bestanden. Crashlogs worden opgeslagen in /metadata/logs/crash_logs.txt.", "MessageM4BFailed": "M4B mislukt!", "MessageM4BFinished": "M4B voltooid!", "MessageMapChapterTitles": "Map hoofdstuktitels naar je bestaande audioboekhoofdstukken zonder aanpassing van tijden", @@ -770,6 +778,7 @@ "MessageNoCollections": "Geen collecties", "MessageNoCoversFound": "Geen covers gevonden", "MessageNoDescription": "Geen beschrijving", + "MessageNoDevices": "Geen Apparaten", "MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment", "MessageNoDownloadsQueued": "Geen downloads in de wachtrij", "MessageNoEpisodeMatchesFound": "Geen afleveringsmatches gevonden", @@ -783,6 +792,7 @@ "MessageNoLogs": "Geen logs", "MessageNoMediaProgress": "Geen mediavoortgang", "MessageNoNotifications": "Geen notificaties", + "MessageNoPodcastFeed": "Ongeldige podcast: Geen Feed", "MessageNoPodcastsFound": "Geen podcasts gevonden", "MessageNoResults": "Geen resultaten", "MessageNoSearchResultsFor": "Geen zoekresultaten voor \"{0}\"", @@ -792,11 +802,17 @@ "MessageNoUpdatesWereNecessary": "Geen bijwerkingen waren noodzakelijk", "MessageNoUserPlaylists": "Je hebt geen afspeellijsten", "MessageNotYetImplemented": "Nog niet geimplementeerd", + "MessageOpmlPreviewNote": "Let op: Dit is een preview van het geparseerde OPML-bestand. De werkelijke podcasttitel wordt overgenomen uit de RSS-feed.", "MessageOr": "of", "MessagePauseChapter": "Pauzeer afspelen hoofdstuk", "MessagePlayChapter": "Luister naar begin van hoofdstuk", "MessagePlaylistCreateFromCollection": "Afspeellijst aanmaken vanuit collectie", + "MessagePleaseWait": "Even geduld...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast heeft geen RSS-feed URL om te gebruiken voor matching", + "MessagePodcastSearchField": "Voer zoekterm of RSS-feed-URL in", + "MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering", + "MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)", + "MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen", "MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.", "MessageRemoveChapter": "Verwijder hoofdstuk", "MessageRemoveEpisodes": "Verwijder {0} aflevering(en)", @@ -807,10 +823,48 @@ "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", "MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.

Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.

Alle clients die van je server gebruik maken zullen automatisch worden ververst.", "MessageSearchResultsFor": "Zoekresultaten voor", + "MessageSelected": "{0} geselecteerd", "MessageServerCouldNotBeReached": "Server niet bereikbaar", "MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel", + "MessageShareExpirationWillBe": "Vervaldatum is {0}", "MessageShareExpiresIn": "Vervalt in {0}", + "MessageShareURLWillBe": "De gedeelde URL wordt {0}", "MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?", + "MessageTaskAudioFileNotWritable": "Audiobestand \"{0}\" is niet beschrijfbaar", + "MessageTaskCanceledByUser": "Taak geannuleerd door gebruiker", + "MessageTaskDownloadingEpisodeDescription": "Aflevering \"{0}\" downloaden", + "MessageTaskEmbeddingMetadata": "Metadata insluiten", + "MessageTaskEmbeddingMetadataDescription": "Metadata insluiten in audioboek \"{0}\"", + "MessageTaskEncodingM4b": "M4B Encoden", + "MessageTaskEncodingM4bDescription": "Audioboek \"{0}\" coderen in één m4b-bestand", + "MessageTaskFailed": "Mislukt", + "MessageTaskFailedToBackupAudioFile": "Het is niet gelukt om een back-up te maken van audiobestand \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Het is niet gelukt om een cachemap te maken", + "MessageTaskFailedToEmbedMetadataInFile": "Het is niet gelukt om metagegevens in bestand \"{0}\" in te sluiten", + "MessageTaskFailedToMergeAudioFiles": "Audiobestanden samenvoegen mislukt", + "MessageTaskFailedToMoveM4bFile": "m4b bestand verplaatsen mislukt", + "MessageTaskFailedToWriteMetadataFile": "Metadata bestand schrijven mislukt", + "MessageTaskMatchingBooksInLibrary": "Overeenkomende boeken in bibliotheek \"{0}\"", + "MessageTaskNoFilesToScan": "Geen bestanden om te scannen", + "MessageTaskOpmlImport": "OPML importeren", + "MessageTaskOpmlImportDescription": "Podcasts maken van {0} RSS feeds", + "MessageTaskOpmlImportFeed": "OPML feed importeren", + "MessageTaskOpmlImportFeedDescription": "RSS feed \"{0}\" importeren", + "MessageTaskOpmlImportFeedFailed": "Podcastfeed kon niet worden opgehaald", + "MessageTaskOpmlImportFeedPodcastDescription": "Podcast \"{0}\" maken", + "MessageTaskOpmlImportFeedPodcastExists": "Podcast bestaat al in pad", + "MessageTaskOpmlImportFeedPodcastFailed": "Mislukt om podcast aan te maken", + "MessageTaskOpmlImportFinished": "{0} podcasts toegevoegd", + "MessageTaskOpmlParseFailed": "Het is niet gelukt om het OPML-bestand te parseren", + "MessageTaskOpmlParseFastFail": "Ongeldig OPML-bestand tag niet gevonden OF een tag is niet gevonden", + "MessageTaskOpmlParseNoneFound": "Geen feeds gevonden in OPML bestand", + "MessageTaskScanItemsAdded": "{0} toegevoegd", + "MessageTaskScanItemsMissing": "{0} missend", + "MessageTaskScanItemsUpdated": "{0} bijgewerkt", + "MessageTaskScanNoChangesNeeded": "Geen aanpassingen nodig", + "MessageTaskScanningFileChanges": "Scannen van bestandswijzigingen in \"{0}\"", + "MessageTaskScanningLibrary": "Scannen van bibliotheek \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "Doelmap is niet beschrijfbaar", "MessageThinking": "Aan het denken...", "MessageUploaderItemFailed": "Uploaden mislukt", "MessageUploaderItemSuccess": "Uploaden gelukt!", @@ -828,34 +882,82 @@ "NoteUploaderFoldersWithMediaFiles": "Mappen met mediabestanden zullen worden behandeld als aparte bibliotheekonderdelen.", "NoteUploaderOnlyAudioFiles": "Bij uploaden van uitsluitend audiobestanden wordt ieder audiobestand als apart audiobook worden behandeld.", "NoteUploaderUnsupportedFiles": "Niet-ondersteunde bestanden worden genegeerd. Bij het kiezen of neerzetten van een map worden andere bestanden die niet in de map staan genegeerd.", + "NotificationOnBackupCompletedDescription": "Wordt geactiveerd wanneer een back-up is voltooid", + "NotificationOnBackupFailedDescription": "Wordt geactiveerd wanneer een back-up mislukt", + "NotificationOnEpisodeDownloadedDescription": "Wordt geactiveerd wanneer een podcastaflevering automatisch wordt gedownload", + "NotificationOnTestDescription": "Event voor het testen van het notificatiesysteem", "PlaceholderNewCollection": "Nieuwe naam collectie", "PlaceholderNewFolderPath": "Nieuwe locatie map", "PlaceholderNewPlaylist": "Nieuwe naam afspeellijst", "PlaceholderSearch": "Zoeken..", "PlaceholderSearchEpisode": "Aflevering zoeken..", + "StatsAuthorsAdded": "auteurs toegevoegd", + "StatsBooksAdded": "boeken toegevoegd", + "StatsBooksAdditional": "Enkele toevoegingen zijn…", + "StatsBooksFinished": "boeken voltooid", + "StatsBooksFinishedThisYear": "Enkele boeken voltooid dit jaar…", + "StatsBooksListenedTo": "geluisterde boeken", + "StatsCollectionGrewTo": "Je boeken collectie groeide tot…", + "StatsSessions": "sessies", + "StatsSpentListening": "tijd geluisterd", + "StatsTopAuthor": "TOP AUTEUR", + "StatsTopAuthors": "TOP AUTEURS", + "StatsTopGenre": "TOP GENRE", + "StatsTopGenres": "TOP GENRES", + "StatsTopMonth": "TOP MAAND", + "StatsTopNarrator": "TOP VERTELLER", + "StatsTopNarrators": "TOP VERTELLERS", + "StatsTotalDuration": "Met een totale tijd van…", + "StatsYearInReview": "JAAR IN REVIEW", "ToastAccountUpdateSuccess": "Account bijgewerkt", + "ToastAppriseUrlRequired": "Moet een Apprise URL invoeren", + "ToastAsinRequired": "ASIN is vereist", "ToastAuthorImageRemoveSuccess": "Afbeelding auteur verwijderd", + "ToastAuthorNotFound": "Auteur \"{0}\" niet gevonden", + "ToastAuthorRemoveSuccess": "Auteur verwijderd", + "ToastAuthorSearchNotFound": "Auteur niet gevonden", "ToastAuthorUpdateMerged": "Auteur samengevoegd", "ToastAuthorUpdateSuccess": "Auteur bijgewerkt", "ToastAuthorUpdateSuccessNoImageFound": "Auteur bijgewerkt (geen afbeelding gevonden)", + "ToastBackupAppliedSuccess": "Backup toegepast", "ToastBackupCreateFailed": "Back-up maken mislukt", "ToastBackupCreateSuccess": "Back-up gemaakt", "ToastBackupDeleteFailed": "Verwijderen back-up mislukt", "ToastBackupDeleteSuccess": "Back-up verwijderd", + "ToastBackupInvalidMaxKeep": "Ongeldig aantal backups om bij te houden", + "ToastBackupInvalidMaxSize": "Ongeldige maximum backupgrootte", "ToastBackupRestoreFailed": "Herstellen back-up mislukt", "ToastBackupUploadFailed": "Uploaden back-up mislukt", "ToastBackupUploadSuccess": "Back-up geüpload", + "ToastBatchDeleteFailed": "Batch verwijderen mislukt", + "ToastBatchDeleteSuccess": "Batch verwijderen gelukt", + "ToastBatchQuickMatchFailed": "Batch Snel Vergelijken mislukt!", + "ToastBatchQuickMatchStarted": "Bulk Snel Vergelijken van {0} boeken gestart!", "ToastBatchUpdateFailed": "Bulk-bijwerking mislukt", "ToastBatchUpdateSuccess": "Bulk-bijwerking gelukt", "ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt", "ToastBookmarkCreateSuccess": "boekwijzer toegevoegd", "ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd", "ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt", + "ToastCachePurgeFailed": "Cache wissen is mislukt", + "ToastCachePurgeSuccess": "Cache succesvol verwijderd", "ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten", "ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben", + "ToastChaptersRemoved": "Hoofdstukken verwijderd", + "ToastChaptersUpdated": "Hoofdstukken bijgewerkt", + "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", + "ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt", "ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie", "ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt", + "ToastCoverUpdateFailed": "Cover update mislukt", + "ToastDeleteFileFailed": "Bestand verwijderen mislukt", + "ToastDeleteFileSuccess": "Bestand verwijderd", + "ToastDeviceAddFailed": "Apparaat toevoegen mislukt", + "ToastDeviceNameAlreadyExists": "Er bestaat al een e-reader met die naam", + "ToastDeviceTestEmailFailed": "Het is niet gelukt om een test-e-mail te verzenden", + "ToastDeviceTestEmailSuccess": "Test e-mail verzonden", + "ToastEmailSettingsUpdateSuccess": "Emaill intellingen bijgewerkt", "ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt", "ToastItemMarkedAsFinishedFailed": "Markeren als Voltooid mislukt", From e534daf5d41a4017c64b0235901cf05160ee0605 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Wed, 23 Oct 2024 04:11:10 +0000 Subject: [PATCH 300/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1067 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 37bf38fc8d..d92d833a57 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "更新通知", "HeaderNotifications": "通知", "HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证", + "HeaderOpenListeningSessions": "打开收听会话", "HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOtherFiles": "其他文件", "HeaderPasswordAuthentication": "密码认证", @@ -226,6 +227,7 @@ "LabelAllUsersExcludingGuests": "除访客外的所有用户", "LabelAllUsersIncludingGuests": "包括访客的所有用户", "LabelAlreadyInYourLibrary": "已存在你的库中", + "LabelApiToken": "API 令牌", "LabelAppend": "附加", "LabelAudioBitrate": "音频比特率 (例如: 128k)", "LabelAudioChannels": "音频通道 (1 或 2)", @@ -463,6 +465,7 @@ "LabelOpenIDGroupClaimDescription": "OpenID 声明的名称, 该声明包含用户组的列表. 通常称为如果已配置, 应用程序将根据用户的组成员身份自动分配角色, 前提是这些组在声明中以不区分大小写的方式命名为 'Admin', 'User' 或 'Guest'. 声明应包含一个列表, 如果用户属于多个组, 则应用程序将分配与最高访问级别相对应的角色. 如果没有组匹配, 访问将被拒绝.", "LabelOpenRSSFeed": "打开 RSS 源", "LabelOverwrite": "覆盖", + "LabelPaginationPageXOfY": "第 {0} 页 共 {1} 页", "LabelPassword": "密码", "LabelPath": "路径", "LabelPermanent": "永久的", From be8c44721688e695d117a5bc87e754ab3dfbbc00 Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Thu, 24 Oct 2024 16:56:07 +0000 Subject: [PATCH 301/539] Translated using Weblate (Czech) Currently translated at 83.5% (891 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index f1c53efc5d..7a898a3708 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -226,8 +226,11 @@ "LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů", "LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů", "LabelAlreadyInYourLibrary": "Již ve vaší knihovně", + "LabelApiToken": "API Token", "LabelAppend": "Připojit", "LabelAudioBitrate": "Bitový tok zvuku (např. 128k)", + "LabelAudioChannels": "Zvukové kanály (1 nebo 2)", + "LabelAudioCodec": "Kodek audia", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (jméno a příjmení)", "LabelAuthorLastFirst": "Autor (příjmení a jméno)", @@ -240,6 +243,7 @@ "LabelAutoRegister": "Automatická registrace", "LabelAutoRegisterDescription": "Automaticky vytvářet nové uživatele po přihlášení", "LabelBackToUser": "Zpět k uživateli", + "LabelBackupAudioFiles": "Zálohovat zvukové soubory", "LabelBackupLocation": "Umístění zálohy", "LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování", "LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups", @@ -248,6 +252,7 @@ "LabelBackupsNumberToKeep": "Počet záloh, které se mají uchovat", "LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.", "LabelBitrate": "Datový tok", + "LabelBonus": "Bonus", "LabelBooks": "Knihy", "LabelButtonText": "Text tlačítka", "LabelByAuthor": "od {0}", From 84003cd67ef29d805f57b4f819a0f27a02199972 Mon Sep 17 00:00:00 2001 From: Henning Date: Thu, 24 Oct 2024 09:37:46 +0000 Subject: [PATCH 302/539] Translated using Weblate (German) Currently translated at 99.5% (1062 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index 37c48d8b6d..f0c167377d 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Benachrichtigung bearbeiten", "HeaderNotifications": "Benachrichtigungen", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", + "HeaderOpenListeningSessions": "Aktive Hörbuch-Sitzungen", "HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOtherFiles": "Sonstige Dateien", "HeaderPasswordAuthentication": "Passwortauthentifizierung", @@ -180,6 +181,7 @@ "HeaderRemoveEpisodes": "Entferne {0} Episoden", "HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte", "HeaderSchedule": "Zeitplan", + "HeaderScheduleEpisodeDownloads": "Automatische Episoden-Downloads planen", "HeaderScheduleLibraryScans": "Automatische Bibliotheksscans", "HeaderSession": "Sitzung", "HeaderSetBackupSchedule": "Zeitplan für die Datensicherung festlegen", @@ -225,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen", "LabelAllUsersIncludingGuests": "Alle Benutzer und Gäste", "LabelAlreadyInYourLibrary": "Bereits in der Bibliothek", + "LabelApiToken": "API Schlüssel", "LabelAppend": "Anhängen", "LabelAudioBitrate": "Audiobitrate (z. B. 128 kbit/s)", "LabelAudioChannels": "Audiokanäle (1 oder 2)", @@ -255,10 +258,12 @@ "LabelByAuthor": "von {0}", "LabelChangePassword": "Passwort ändern", "LabelChannels": "Kanäle", + "LabelChapterCount": "{0} Kapitel", "LabelChapterTitle": "Kapitelüberschrift", "LabelChapters": "Kapitel", "LabelChaptersFound": "Gefundene Kapitel", "LabelClickForMoreInfo": "Klicken für mehr Informationen", + "LabelClickToUseCurrentValue": "Anklicken um aktuellen Wert zu verwenden", "LabelClosePlayer": "Player schließen", "LabelCodec": "Codec", "LabelCollapseSeries": "Serien einklappen", @@ -316,11 +321,15 @@ "LabelEncodingStartedNavigation": "Sobald die Aufgabe gestartet ist, kann die Seite verlassen werden.", "LabelEncodingTimeWarning": "Kodierung kann bis zu 30 Minuten dauern.", "LabelEncodingWarningAdvancedSettings": "Achtung: Ändere diese Einstellungen nur, wenn du dich mit ffmpeg Kodierung auskennst.", + "LabelEncodingWatcherDisabled": "Wenn der Watcher deaktiviert ist musst du das Hörbuch danach erneut scannen.", "LabelEnd": "Ende", "LabelEndOfChapter": "Ende des Kapitels", "LabelEpisode": "Episode", + "LabelEpisodeNotLinkedToRssFeed": "Episode nicht mit RSS-Feed verknüpft", + "LabelEpisodeNumber": "Episode #{0}", "LabelEpisodeTitle": "Episodentitel", "LabelEpisodeType": "Episodentyp", + "LabelEpisodeUrlFromRssFeed": "Episoden URL vom RSS-Feed", "LabelEpisodes": "Episoden", "LabelExample": "Beispiel", "LabelExpandSeries": "Serie ausklappen", @@ -349,6 +358,7 @@ "LabelFontScale": "Schriftgröße", "LabelFontStrikethrough": "Durchgestrichen", "LabelFormat": "Format", + "LabelFull": "Voll", "LabelGenre": "Kategorie", "LabelGenres": "Kategorien", "LabelHardDeleteFile": "Datei dauerhaft löschen", @@ -404,6 +414,10 @@ "LabelLowestPriority": "Niedrigste Priorität", "LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit", "LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID vom SSO-Anbieter zugeordnet", + "LabelMaxEpisodesToDownload": "Max. Anzahl an Episoden zum Herunterladen, 0 für unbegrenzte Episoden.", + "LabelMaxEpisodesToDownloadPerCheck": "Max. Anzahl neuer Episoden zum Herunterladen pro Abfrage", + "LabelMaxEpisodesToKeep": "Max. Anzahl zu behaltender Episoden", + "LabelMaxEpisodesToKeepHelp": "0 setzt keine Begrenzung. Wenn eine neue Episode automatisch heruntergeladen wird, wird die älteste Episode gelöscht, wenn du mehr als X Episoden gespeichert hast. Es wird nur eine Episode pro neuem Download gelöscht.", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", "LabelMetaTag": "Meta Schlagwort", @@ -449,6 +463,7 @@ "LabelOpenIDGroupClaimDescription": "Name des OpenID-Claims, der eine Liste der Benutzergruppen enthält. Wird häufig als groups bezeichnet. Wenn konfiguriert, wird die Anwendung automatisch Rollen basierend auf den Gruppenmitgliedschaften des Benutzers zuweisen, vorausgesetzt, dass diese Gruppen im Claim als 'admin', 'user' oder 'guest' benannt sind (Groß/Kleinschreibung ist irrelevant). Der Claim eine Liste sein, und wenn ein Benutzer mehreren Gruppen angehört, wird die Anwendung die Rolle zuordnen, die dem höchsten Zugriffslevel entspricht. Wenn keine Gruppe übereinstimmt, wird der Zugang verweigert.", "LabelOpenRSSFeed": "Öffne RSS-Feed", "LabelOverwrite": "Überschreiben", + "LabelPaginationPageXOfY": "Seite {0} von {1}", "LabelPassword": "Passwort", "LabelPath": "Pfad", "LabelPermanent": "Dauerhaft", @@ -499,12 +514,17 @@ "LabelRedo": "Wiederholen", "LabelRegion": "Region", "LabelReleaseDate": "Veröffentlichungsdatum", + "LabelRemoveAllMetadataAbs": "Alle metadata.abs Dateien löschen", + "LabelRemoveAllMetadataJson": "Alle metadata.json Dateien löschen", "LabelRemoveCover": "Entferne Titelbild", + "LabelRemoveMetadataFile": "Metadaten-Dateien in Bibliotheksordnern löschen", + "LabelRemoveMetadataFileHelp": "Alle metadata.json und metadata.abs Dateien aus den Ordnern {0} löschen.", "LabelRowsPerPage": "Zeilen pro Seite", "LabelSearchTerm": "Begriff suchen", "LabelSearchTitle": "Titel suchen", "LabelSearchTitleOrASIN": "Titel oder ASIN suchen", "LabelSeason": "Staffel", + "LabelSeasonNumber": "Staffel #{0}", "LabelSelectAll": "Alles auswählen", "LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", @@ -603,6 +623,7 @@ "LabelTimeDurationXMinutes": "{0} Minuten", "LabelTimeDurationXSeconds": "{0} Sekunden", "LabelTimeInMinutes": "Zeit in Minuten", + "LabelTimeLeft": "{0} verbleibend", "LabelTimeListened": "Gehörte Zeit", "LabelTimeListenedToday": "Heute gehörte Zeit", "LabelTimeRemaining": "{0} verbleibend", @@ -639,6 +660,7 @@ "LabelUseAdvancedOptions": "Nutze Erweiterte Optionen", "LabelUseChapterTrack": "Kapiteldatei verwenden", "LabelUseFullTrack": "Gesamte Datei verwenden", + "LabelUseZeroForUnlimited": "0 für unbegrenzt", "LabelUser": "Benutzer", "LabelUsername": "Benutzername", "LabelValue": "Wert", @@ -704,6 +726,7 @@ "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?", + "MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?", "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Bist du dir sicher?", @@ -719,6 +742,7 @@ "MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge", "MessageEmbedFailed": "Einbetten fehlgeschlagen!", "MessageEmbedFinished": "Einbettung abgeschlossen!", + "MessageEmbedQueue": "Eingereiht zum einbinden von Metadaten ({0} in Warteschlange)", "MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen", "MessageEreaderDevices": "Um die Zustellung von E-Büchern sicherzustellen, musst du eventuell die oben genannte E-Mail-Adresse als gültigen Absender für jedes unten aufgeführte Gerät hinzufügen.", "MessageFeedURLWillBe": "Feed-URL wird {0} sein", @@ -780,6 +804,10 @@ "MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung", "MessagePleaseWait": "Bitte warten...", "MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann", + "MessagePodcastSearchField": "Suchbegriff oder RSS-Feed URL eingeben", + "MessageQuickEmbedInProgress": "Schnellabgleich läuft", + "MessageQuickEmbedQueue": "In Warteschlange für Schnelles einbinden ({0} eingereiht)", + "MessageQuickMatchAllEpisodes": "Quick Match aller Episoden", "MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.", "MessageRemoveChapter": "Kapitel entfernen", "MessageRemoveEpisodes": "Entferne {0} Episode(n)", @@ -822,6 +850,9 @@ "MessageTaskOpmlImportFeedPodcastExists": "Der Podcast ist bereits im Pfad vorhanden", "MessageTaskOpmlImportFeedPodcastFailed": "Erstellen des Podcasts fehlgeschlagen", "MessageTaskOpmlImportFinished": "{0} Podcasts hinzugefügt", + "MessageTaskOpmlParseFailed": "Fehler beim lesen der OPML Datei", + "MessageTaskOpmlParseFastFail": "Ungültie OPML Datei: ODER tag wurde nicht gefunden", + "MessageTaskOpmlParseNoneFound": "Keine feeds in der OPML Datei gefunden", "MessageTaskScanItemsAdded": "{0} hinzugefügt", "MessageTaskScanItemsMissing": "{0} fehlend", "MessageTaskScanItemsUpdated": "{0} aktualisiert", @@ -846,6 +877,10 @@ "NoteUploaderFoldersWithMediaFiles": "Ordner mit Mediendateien werden als separate Bibliothekselemente behandelt.", "NoteUploaderOnlyAudioFiles": "Wenn du nur Audiodateien hochlädst, wird jede Audiodatei als ein separates Medium behandelt.", "NoteUploaderUnsupportedFiles": "Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.", + "NotificationOnBackupCompletedDescription": "Wird ausgeführt wenn ein Backup erstellt wurde", + "NotificationOnBackupFailedDescription": "Wird ausgeführt wenn ein Backup fehlgeschlagen ist", + "NotificationOnEpisodeDownloadedDescription": "Wird ausgeführt wenn eine Podcast Folge automatisch heruntergeladen wird", + "NotificationOnTestDescription": "Wird ausgeführt wenn das Benachrichtigungssystem getestet wird", "PlaceholderNewCollection": "Neuer Sammlungsname", "PlaceholderNewFolderPath": "Neuer Ordnerpfad", "PlaceholderNewPlaylist": "Neuer Wiedergabelistenname", @@ -871,6 +906,7 @@ "StatsYearInReview": "DAS JAHR IM RÜCKBLICK", "ToastAccountUpdateSuccess": "Konto aktualisiert", "ToastAppriseUrlRequired": "Eine Apprise-URL ist notwendig", + "ToastAsinRequired": "ASIN ist erforderlich", "ToastAuthorImageRemoveSuccess": "Autorenbild entfernt", "ToastAuthorNotFound": "Autor \"{0}\" nicht gefunden", "ToastAuthorRemoveSuccess": "Autor entfernt", @@ -890,6 +926,8 @@ "ToastBackupUploadSuccess": "Sicherung hochgeladen", "ToastBatchDeleteFailed": "Batch-Löschen fehlgeschlagen", "ToastBatchDeleteSuccess": "Batch-Löschung erfolgreich", + "ToastBatchQuickMatchFailed": "Batch-Schnellabgleich fehlgeschlagen!", + "ToastBatchQuickMatchStarted": "Batch-Schnellabgleich für {0} Bücher gestartet!", "ToastBatchUpdateFailed": "Stapelaktualisierung fehlgeschlagen", "ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich", "ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden", @@ -901,6 +939,7 @@ "ToastChaptersHaveErrors": "Kapitel sind fehlerhaft", "ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen", "ToastChaptersRemoved": "Kapitel entfernt", + "ToastChaptersUpdated": "Kapitel aktualisiert", "ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen", "ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt", "ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt", @@ -918,11 +957,14 @@ "ToastEncodeCancelSucces": "Encoding abgebrochen", "ToastEpisodeDownloadQueueClearFailed": "Warteschlange konnte nicht gelöscht werden", "ToastEpisodeDownloadQueueClearSuccess": "Warteschlange für Episoden-Downloads gelöscht", + "ToastEpisodeUpdateSuccess": "{0} Episoden aktualisiert", "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", "ToastFailedToLoadData": "Daten laden fehlgeschlagen", + "ToastFailedToMatch": "Fehler beim Abgleich", "ToastFailedToShare": "Fehler beim Teilen", "ToastFailedToUpdate": "Aktualisierung ist fehlgeschlagen", "ToastInvalidImageUrl": "Ungültiger Bild URL", + "ToastInvalidMaxEpisodesToDownload": "Ungültige Max. Anzahl an Episoden zum Herunterladen", "ToastInvalidUrl": "Ungültiger URL", "ToastItemCoverUpdateSuccess": "Titelbild aktualisiert", "ToastItemDeletedFailed": "Fehler beim löschen des Artikels", @@ -941,14 +983,21 @@ "ToastLibraryScanStarted": "Bibliotheksscan gestartet", "ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert", "ToastMatchAllAuthorsFailed": "Nicht alle Autoren konnten zugeordnet werden", + "ToastMetadataFilesRemovedError": "Fehler beim löschen von metadata.{0} Dateien", + "ToastMetadataFilesRemovedNoneFound": "Keine metadata.{0} Dateien in Bibliothek gefunden", + "ToastMetadataFilesRemovedNoneRemoved": "Keine metadata.{0} Dateien gelöscht", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} Datei(en) gelöscht", + "ToastMustHaveAtLeastOnePath": "Es muss mindestens ein Pfad angegeben werden", "ToastNameEmailRequired": "Name und E-Mail sind erforderlich", "ToastNameRequired": "Name ist erforderlich", + "ToastNewEpisodesFound": "{0} neue Episoden gefunden", "ToastNewUserCreatedFailed": "Fehler beim erstellen des Accounts: \"{ 0}\"", "ToastNewUserCreatedSuccess": "Neuer Account erstellt", "ToastNewUserLibraryError": "Mindestens eine Bibliothek muss ausgewählt werden", "ToastNewUserPasswordError": "Passwort erforderlich, nur der root Benutzer darf ein leeres Passwort haben", "ToastNewUserTagError": "Mindestens ein Tag muss ausgewählt sein", "ToastNewUserUsernameError": "Nutzername eingeben", + "ToastNoNewEpisodesFound": "Keine neuen Episoden gefunden", "ToastNoUpdatesNecessary": "Keine Änderungen nötig", "ToastNotificationCreateFailed": "Fehler beim erstellen der Benachrichtig", "ToastNotificationDeleteFailed": "Fehler beim löschen der Benachrichtigung", @@ -967,6 +1016,7 @@ "ToastPodcastGetFeedFailed": "Fehler beim abrufen des Podcast Feeds", "ToastPodcastNoEpisodesInFeed": "Keine Episoden in RSS Feed gefunden", "ToastPodcastNoRssFeed": "Podcast enthält keinen RSS Feed", + "ToastProgressIsNotBeingSynced": "Fortschritt wird nicht synchronisiert, Wiedergabe wird neu gestartet", "ToastProviderCreatedFailed": "Fehler beim hinzufügen des Anbieters", "ToastProviderCreatedSuccess": "Neuer Anbieter hinzugefügt", "ToastProviderNameAndUrlRequired": "Name und URL notwendig", @@ -993,6 +1043,7 @@ "ToastSessionCloseFailed": "Fehler beim schließen der Sitzung", "ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden", "ToastSessionDeleteSuccess": "Sitzung gelöscht", + "ToastSleepTimerDone": "Einschlaf-Timer aktiviert... zZzzZz", "ToastSlugMustChange": "URL-Schlüssel enthält ungültige Zeichen", "ToastSlugRequired": "URL-Schlüssel erforderlich", "ToastSocketConnected": "Verbindung zum WebSocket hergestellt", From 9ba2ecbc216cb671a35b76e289feba9c592e41fb Mon Sep 17 00:00:00 2001 From: biuklija Date: Thu, 24 Oct 2024 17:34:38 +0000 Subject: [PATCH 303/539] Translated using Weblate (Croatian) Currently translated at 100.0% (1067 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 92636a1d49..54a7ce02db 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Ažuriraj obavijest", "HeaderNotifications": "Obavijesti", "HeaderOpenIDConnectAuthentication": "Prijava na OpenID Connect", + "HeaderOpenListeningSessions": "Otvorene sesije slušanja", "HeaderOpenRSSFeed": "Otvori RSS izvor", "HeaderOtherFiles": "Druge datoteke", "HeaderPasswordAuthentication": "Provjera autentičnosti zaporkom", @@ -226,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Svi korisnici osim gostiju", "LabelAllUsersIncludingGuests": "Svi korisnici uključujući i goste", "LabelAlreadyInYourLibrary": "Već u vašoj knjižnici", + "LabelApiToken": "API Token", "LabelAppend": "Pridodaj", "LabelAudioBitrate": "Kvaliteta zvučnog zapisa (npr. 128k)", "LabelAudioChannels": "Broj zvučnih kanala (1 ili 2)", @@ -463,6 +465,7 @@ "LabelOpenIDGroupClaimDescription": "Naziv OpenID zahtjeva koji sadrži popis korisnikovih grupa. Često se naziva groups. Ako se konfigurira, aplikacija će automatski dodijeliti uloge temeljem korisnikovih članstava u grupama, pod uvjetom da se iste zovu 'admin', 'user' ili 'guest' u zahtjevu (ne razlikuju se velika i mala slova). Zahtjev treba sadržavati popis i ako je korisnik član više grupa, aplikacija će dodijeliti ulogu koja odgovara najvišoj razini pristupa. Ukoliko se niti jedna grupa ne podudara, pristup će biti onemogućen.", "LabelOpenRSSFeed": "Otvori RSS Feed", "LabelOverwrite": "Prepiši", + "LabelPaginationPageXOfY": "Stranica {0} od {1}", "LabelPassword": "Zaporka", "LabelPath": "Putanja", "LabelPermanent": "Trajno", From d576efe759a1b104378af99dae1d71eff9e24d09 Mon Sep 17 00:00:00 2001 From: Mathias Franco Date: Thu, 24 Oct 2024 09:29:56 +0000 Subject: [PATCH 304/539] Translated using Weblate (Dutch) Currently translated at 100.0% (1067 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 76 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index 8c9499d73c..bc5a40ca25 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -958,12 +958,28 @@ "ToastDeviceTestEmailFailed": "Het is niet gelukt om een test-e-mail te verzenden", "ToastDeviceTestEmailSuccess": "Test e-mail verzonden", "ToastEmailSettingsUpdateSuccess": "Emaill intellingen bijgewerkt", + "ToastEncodeCancelFailed": "Het is niet gelukt om het coderen te annuleren", + "ToastEncodeCancelSucces": "Encode geannuleerd", + "ToastEpisodeDownloadQueueClearFailed": "Wachtrij legen mislukt", + "ToastEpisodeDownloadQueueClearSuccess": "Aflevering download-wachtrij geleegt", + "ToastEpisodeUpdateSuccess": "{0} afleveringen bijgewerkt", + "ToastErrorCannotShare": "Kan niet native delen op dit apparaat", + "ToastFailedToLoadData": "Data laden mislukt", + "ToastFailedToMatch": "Match mislukt", + "ToastFailedToShare": "Delen mislukt", + "ToastFailedToUpdate": "Update mislukt", + "ToastInvalidImageUrl": "Ongeldige afbeeldings-URL", + "ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden", + "ToastInvalidUrl": "Ongeldige URL", "ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt", + "ToastItemDeletedFailed": "Item verwijderen mislukt", + "ToastItemDeletedSuccess": "Verwijderd item", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt", "ToastItemMarkedAsFinishedFailed": "Markeren als Voltooid mislukt", "ToastItemMarkedAsFinishedSuccess": "Onderdeel gemarkeerd als Voltooid", "ToastItemMarkedAsNotFinishedFailed": "Markeren als Niet Voltooid mislukt", "ToastItemMarkedAsNotFinishedSuccess": "Onderdeel gemarkeerd als Niet Voltooid", + "ToastItemUpdateSuccess": "Item bijgewerkt", "ToastLibraryCreateFailed": "Bibliotheek aanmaken mislukt", "ToastLibraryCreateSuccess": "Bibliotheek \"{0}\" aangemaakt", "ToastLibraryDeleteFailed": "Bibliotheek verwijderen mislukt", @@ -971,25 +987,83 @@ "ToastLibraryScanFailedToStart": "Starten scan mislukt", "ToastLibraryScanStarted": "Scannen bibliotheek gestart", "ToastLibraryUpdateSuccess": "Bibliotheek \"{0}\" bijgewerkt", + "ToastMatchAllAuthorsFailed": "Alle auteurs matchen mislukt", + "ToastMetadataFilesRemovedError": "Fout bij verwijderen van metadata. {0} bestanden", + "ToastMetadataFilesRemovedNoneFound": "Geen metadata. {0} bestanden gevonden in bibliotheek", + "ToastMetadataFilesRemovedNoneRemoved": "Geen metadata. {0} bestanden verwijderd", + "ToastMetadataFilesRemovedSuccess": "{0} metadata. {1} bestanden verwijderd", + "ToastMustHaveAtLeastOnePath": "Moet ten minste een pad hebben", + "ToastNameEmailRequired": "Naam en email zijn vereist", + "ToastNameRequired": "Naam is vereist", + "ToastNewEpisodesFound": "{0} nieuwe afleveringen gevonden", + "ToastNewUserCreatedFailed": "Account: \"{0}\" aanmaken mislukt", + "ToastNewUserCreatedSuccess": "Nieuw account aangemaakt", + "ToastNewUserLibraryError": "Moet ten minste een bibliotheek selecteren", + "ToastNewUserPasswordError": "Moet een wachtwoord hebben, enkel root gebruiker kan een leeg wachtwoord gebruiken", + "ToastNewUserTagError": "Moet ten minste een tag selecteren", + "ToastNewUserUsernameError": "Voer een gebruikersnaam in", + "ToastNoNewEpisodesFound": "Geen nieuwe afleveringen gevonden", + "ToastNoUpdatesNecessary": "Geen updates nodig", + "ToastNotificationCreateFailed": "Nieuwe melding aanmaken mislukt", + "ToastNotificationDeleteFailed": "Melding verwijderen mislukt", + "ToastNotificationFailedMaximum": "Maximum aantal pogingen moet >=0", + "ToastNotificationQueueMaximum": "Maximale meldingen wachtrij moet >=0", + "ToastNotificationSettingsUpdateSuccess": "Meldingsinstellingen bijgewerkt", + "ToastNotificationTestTriggerFailed": "Het is niet gelukt om een testmelding te activeren", + "ToastNotificationTestTriggerSuccess": "Geactiveerde testmelding", + "ToastNotificationUpdateSuccess": "Melding bijgewerkt", "ToastPlaylistCreateFailed": "Aanmaken afspeellijst mislukt", "ToastPlaylistCreateSuccess": "Afspeellijst aangemaakt", "ToastPlaylistRemoveSuccess": "Afspeellijst verwijderd", "ToastPlaylistUpdateSuccess": "Afspeellijst bijgewerkt", "ToastPodcastCreateFailed": "Podcast aanmaken mislukt", "ToastPodcastCreateSuccess": "Podcast aangemaakt", + "ToastPodcastGetFeedFailed": "Podcast feed ophalen mislukt", + "ToastPodcastNoEpisodesInFeed": "Geen afleveringen gevonden in RSS feed", + "ToastPodcastNoRssFeed": "Podcast heeft geen RSS feed", + "ToastProgressIsNotBeingSynced": "De voortgang wordt niet gesynchroniseerd, start het afspelen opnieuw", + "ToastProviderCreatedFailed": "Provider toevoegen mislukt", + "ToastProviderCreatedSuccess": "Nieuwe provider toegevoegd", + "ToastProviderNameAndUrlRequired": "Naam en URL vereist", + "ToastProviderRemoveSuccess": "Provider verwijderd", "ToastRSSFeedCloseFailed": "Sluiten RSS-feed mislukt", "ToastRSSFeedCloseSuccess": "RSS-feed gesloten", + "ToastRemoveFailed": "Verwijderen mislukt", "ToastRemoveItemFromCollectionFailed": "Onderdeel verwijderen uit collectie mislukt", "ToastRemoveItemFromCollectionSuccess": "Onderdeel verwijderd uit collectie", + "ToastRemoveItemsWithIssuesFailed": "Verwijderen van bibliotheekitems met problemen mislukt", + "ToastRemoveItemsWithIssuesSuccess": "Bibliotheekitems met problemen verwijderd", + "ToastRenameFailed": "Hernoemen mislukt", + "ToastRescanFailed": "Opnieuw scannen mislukt voor {0}", + "ToastRescanRemoved": "Opnieuw scannen voltooid, item is verwijderd", + "ToastRescanUpToDate": "Rescan voltooid, item is up to date", + "ToastRescanUpdated": "Rescan voltooid, item is geupdated", + "ToastScanFailed": "Bibliotheek item scannen mislukt", + "ToastSelectAtLeastOneUser": "Selecteer ten minste een gebruiker", "ToastSendEbookToDeviceFailed": "Ebook naar apparaat sturen mislukt", "ToastSendEbookToDeviceSuccess": "Ebook verstuurd naar apparaat \"{0}\"", "ToastSeriesUpdateFailed": "Bijwerken serie mislukt", "ToastSeriesUpdateSuccess": "Bijwerken serie gelukt", + "ToastServerSettingsUpdateSuccess": "Server instellingen bijgewerkt", + "ToastSessionCloseFailed": "Sessie sluiten mislukt", "ToastSessionDeleteFailed": "Verwijderen sessie mislukt", "ToastSessionDeleteSuccess": "Sessie verwijderd", + "ToastSleepTimerDone": "Slaap timer voltooid... zZzzZz", + "ToastSlugMustChange": "Slug bevat ongeldige symbolen", + "ToastSlugRequired": "Slug is vereist", "ToastSocketConnected": "Socket verbonden", "ToastSocketDisconnected": "Socket niet verbonden", "ToastSocketFailedToConnect": "Verbinding Socket mislukt", + "ToastSortingPrefixesEmptyError": "Moet ten minste 1 sorteer-prefix bevatten", + "ToastSortingPrefixesUpdateSuccess": "Sorteer prefixes geupdated ({0} items)", + "ToastTitleRequired": "Titel is vereist", + "ToastUnknownError": "Onbekende fout", + "ToastUnlinkOpenIdFailed": "Gebruiker ontkoppelen van OpenID mislukt", + "ToastUnlinkOpenIdSuccess": "Gebruiker ontkoppeld van OpenID", "ToastUserDeleteFailed": "Verwijderen gebruiker mislukt", - "ToastUserDeleteSuccess": "Gebruiker verwijderd" + "ToastUserDeleteSuccess": "Gebruiker verwijderd", + "ToastUserPasswordChangeSuccess": "Wachtwoord succesvol gewijzigd", + "ToastUserPasswordMismatch": "Wachtwoorden komen niet overeen", + "ToastUserPasswordMustChange": "Het nieuwe wachtwoord kan niet overeenkomen met het oude wachtwoord", + "ToastUserRootRequireName": "U moet een root-gebruikersnaam invoeren" } From 69a639f76c7ffddcf75778c7f310c2fb391ba337 Mon Sep 17 00:00:00 2001 From: Ahetek Date: Thu, 24 Oct 2024 06:57:13 +0000 Subject: [PATCH 305/539] Translated using Weblate (Polish) Currently translated at 75.5% (806 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/strings/pl.json b/client/strings/pl.json index 57bb577a01..85c7b769b1 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -64,6 +64,7 @@ "ButtonPurgeItemsCache": "Wyczyść dane tymczasowe pozycji", "ButtonQueueAddItem": "Dodaj do kolejki", "ButtonQueueRemoveItem": "Usuń z kolejki", + "ButtonQuickEmbed": "Szybkie wstawienie", "ButtonQuickEmbedMetadata": "Szybkie wstawianie metadanych", "ButtonQuickMatch": "Szybkie dopasowanie", "ButtonReScan": "Ponowne skanowanie", @@ -95,7 +96,7 @@ "ButtonStartM4BEncode": "Eksportuj jako plik M4B", "ButtonStartMetadataEmbed": "Osadź metadane", "ButtonStats": "Statystyki", - "ButtonSubmit": "Pobierz", + "ButtonSubmit": "Zapisz", "ButtonTest": "Test", "ButtonUnlinkOpenId": "Odłącz OpenID", "ButtonUpload": "Wgraj", @@ -138,6 +139,7 @@ "HeaderFindChapters": "Wyszukaj rozdziały", "HeaderIgnoredFiles": "Zignoruj pliki", "HeaderItemFiles": "Pliki", + "HeaderItemMetadataUtils": "Narzędzia dla metadanych", "HeaderLastListeningSession": "Ostatnia sesja słuchania", "HeaderLatestEpisodes": "Najnowsze odcinki", "HeaderLibraries": "Biblioteki", @@ -176,6 +178,7 @@ "HeaderRemoveEpisodes": "Usuń {0} odcinków", "HeaderSavedMediaProgress": "Zapisany postęp", "HeaderSchedule": "Harmonogram", + "HeaderScheduleEpisodeDownloads": "Planowanie automatycznego ściągania odcinków", "HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki", "HeaderSession": "Sesja", "HeaderSetBackupSchedule": "Ustaw harmonogram tworzenia kopii zapasowej", @@ -221,7 +224,11 @@ "LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości", "LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi", "LabelAlreadyInYourLibrary": "Już istnieje w twojej bibliotece", + "LabelApiToken": "API Token", "LabelAppend": "Dołącz", + "LabelAudioBitrate": "Audio Bitrate (np. 128k)", + "LabelAudioChannels": "Kanały dźwięku (1 lub 2)", + "LabelAudioCodec": "Kodek audio", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (Rosnąco)", "LabelAuthorLastFirst": "Author (Malejąco)", @@ -233,6 +240,7 @@ "LabelAutoRegister": "Automatyczna rejestracja", "LabelAutoRegisterDescription": "Automatycznie utwórz nowych użytkowników po zalogowaniu", "LabelBackToUser": "Powrót", + "LabelBackupAudioFiles": "Kopia zapasowa plików audio", "LabelBackupLocation": "Lokalizacja kopii zapasowej", "LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe", "LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups", @@ -241,15 +249,18 @@ "LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania", "LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.", "LabelBitrate": "Bitrate", + "LabelBonus": "Bonus", "LabelBooks": "Książki", "LabelButtonText": "Tekst przycisku", "LabelByAuthor": "autorstwa {0}", "LabelChangePassword": "Zmień hasło", "LabelChannels": "Kanały", + "LabelChapterCount": "{0} rozdziałów", "LabelChapterTitle": "Tytuł rozdziału", "LabelChapters": "Rozdziały", "LabelChaptersFound": "Znalezione rozdziały", "LabelClickForMoreInfo": "Kliknij po więcej szczegółów", + "LabelClickToUseCurrentValue": "Kliknij by zastosować aktualną wartość", "LabelClosePlayer": "Zamknij odtwarzacz", "LabelCodec": "Kodek", "LabelCollapseSeries": "Podsumuj serię", @@ -299,6 +310,7 @@ "LabelEmailSettingsTestAddress": "Adres testowy", "LabelEmbeddedCover": "Wbudowana okładka", "LabelEnable": "Włącz", + "LabelEncodingBackupLocation": "Kopia zapasowa twoich oryginalnych plików audio będzie się znajdować w:", "LabelEnd": "Zakończ", "LabelEndOfChapter": "Koniec rozdziału", "LabelEpisode": "Odcinek", From d9c345b0f38e2ceb5403d16366bc1a718abb69b6 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Fri, 25 Oct 2024 12:08:14 +0000 Subject: [PATCH 306/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1070 of 1070 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index d92d833a57..1754bf5642 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -562,6 +562,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "只有一本书的系列将从系列页面和主页书架中隐藏.", "LabelSettingsHomePageBookshelfView": "首页使用书架视图", "LabelSettingsLibraryBookshelfView": "媒体库使用书架视图", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "完成百分比大于", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "剩余时间少于 (秒)", + "LabelSettingsLibraryMarkAsFinishedWhen": "当发生以下情况时将媒体项目标记为已完成", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "跳过继续系列中的早期书籍", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "继续系列主页书架显示系列中未开始的第一本书, 该系列至少有一本书已完成且没有正在进行的书. 启用此设置将从最远完成的书开始系列, 而不是从第一本书开始.", "LabelSettingsParseSubtitles": "解析副标题", From 449dc1a0e290b7a777193ebb66f7800c83232fdd Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 25 Oct 2024 09:13:00 +0000 Subject: [PATCH 307/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1070 of 1070 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index dadb787b03..d07696f00d 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Posodobi obvestilo", "HeaderNotifications": "Obvestila", "HeaderOpenIDConnectAuthentication": "Prijava z OpenID Connect", + "HeaderOpenListeningSessions": "Odprte seje poslušanja", "HeaderOpenRSSFeed": "Odpri vir RSS", "HeaderOtherFiles": "Ostale datoteke", "HeaderPasswordAuthentication": "Preverjanje pristnosti z geslom", @@ -226,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Vsi uporabniki razen gosti", "LabelAllUsersIncludingGuests": "Vsi uporabniki vključno z gosti", "LabelAlreadyInYourLibrary": "Že v tvoji knjižnici", + "LabelApiToken": "API žeton", "LabelAppend": "Priloži", "LabelAudioBitrate": "Avdio bitna hitrost (npr. 128k)", "LabelAudioChannels": "Avdio kanali (1 ali 2)", @@ -463,6 +465,7 @@ "LabelOpenIDGroupClaimDescription": "Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane skupine. Če je konfigurirana, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.", "LabelOpenRSSFeed": "Odpri vir RSS", "LabelOverwrite": "Prepiši", + "LabelPaginationPageXOfY": "Stran {0} od {1}", "LabelPassword": "Geslo", "LabelPath": "Pot", "LabelPermanent": "Trajno", @@ -559,6 +562,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Serije, ki imajo eno knjigo, bodo skrite na strani serije in policah domače strani.", "LabelSettingsHomePageBookshelfView": "Domača stran bo imela pogled knjižne police", "LabelSettingsLibraryBookshelfView": "Knjižnična uporaba pogleda knjižne police", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Odstotek dokončanega je večji od", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostali čas je manj kot (sekund)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medijski element kot končan, ko", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči prejšnje knjige v nadaljevanju serije", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.", "LabelSettingsParseSubtitles": "Uporabi podnapise", From fba9cce82ec1a94e002d34a6b012eadf34c759a0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 27 Oct 2024 15:15:44 -0500 Subject: [PATCH 308/539] Version bump v2.16.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index d850276660..e82b551440 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.15.1", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.15.1", + "version": "2.16.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 9b9baf0ad0..441af9afd8 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.15.1", + "version": "2.16.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index e17041a4ba..15c1622141 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.15.1", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.15.1", + "version": "2.16.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 26ab93db7d..723cafe066 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.15.1", + "version": "2.16.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 9084055b95417340c003b2f3946753d8f133289d Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 28 Oct 2024 08:03:31 +0200 Subject: [PATCH 309/539] Add proper error handing for file downloads --- server/controllers/LibraryItemController.js | 59 +++++++++++++++------ 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index fe8539bc32..0b4d3d0cd2 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -115,6 +115,16 @@ class LibraryItemController { res.sendStatus(200) } + #handleDownloadError(error, res) { + if (!res.headersSent) { + if (error.code === 'ENOENT') { + return res.status(404).send('File not found') + } else { + return res.status(500).send('Download failed') + } + } + } + /** * GET: /api/items/:id/download * Download library item. Zip file if multiple files. @@ -122,7 +132,7 @@ class LibraryItemController { * @param {RequestWithUser} req * @param {Response} res */ - download(req, res) { + async download(req, res) { if (!req.user.canDownload) { Logger.warn(`User "${req.user.username}" attempted to download without permission`) return res.sendStatus(403) @@ -130,21 +140,26 @@ class LibraryItemController { const libraryItemPath = req.libraryItem.path const itemTitle = req.libraryItem.media.metadata.title - // If library item is a single file in root dir then no need to zip - if (req.libraryItem.isFile) { - // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available - const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath)) - if (audioMimeType) { - res.setHeader('Content-Type', audioMimeType) + Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) + + try { + // If library item is a single file in root dir then no need to zip + if (req.libraryItem.isFile) { + // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available + const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath)) + if (audioMimeType) { + res.setHeader('Content-Type', audioMimeType) + } + await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve()))) + } else { + const filename = `${itemTitle}.zip` + await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res) } - Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) - res.download(libraryItemPath, req.libraryItem.relPath) - return + Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`) + } catch (error) { + Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error) + this.#handleDownloadError(error, res) } - - Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) - const filename = `${itemTitle}.zip` - zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res) } /** @@ -845,7 +860,13 @@ class LibraryItemController { res.setHeader('Content-Type', audioMimeType) } - res.download(libraryFile.metadata.path, libraryFile.metadata.filename) + try { + await new Promise((resolve, reject) => res.download(libraryFile.metadata.path, libraryFile.metadata.filename, (error) => (error ? reject(error) : resolve()))) + Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error) + this.#handleDownloadError(error, res) + } } /** @@ -883,7 +904,13 @@ class LibraryItemController { return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() } - res.sendFile(ebookFilePath) + try { + await new Promise((resolve, reject) => res.sendFile(ebookFilePath, (error) => (error ? reject(error) : resolve()))) + Logger.info(`[LibraryItemController] Downloaded ebook file "${ebookFilePath}"`) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to download ebook file "${ebookFilePath}"`, error) + this.#handleDownloadError(error, res) + } } /** From 8f113d17c231df6502a4f2e1697ec9f7228b5470 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 28 Oct 2024 16:57:37 -0500 Subject: [PATCH 310/539] Fix:Ensure library has all settings defined when validating settings for update #3559 --- server/controllers/LibraryController.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 82fd34f07d..61ffb5bd2d 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -235,12 +235,14 @@ class LibraryController { for (const key of keysToCheck) { if (!req.body[key]) continue if (typeof req.body[key] !== 'string') { + Logger.error(`[LibraryController] Invalid request. ${key} must be a string`) return res.status(400).send(`Invalid request. ${key} must be a string`) } updatePayload[key] = req.body[key] } if (req.body.displayOrder !== undefined) { if (isNaN(req.body.displayOrder)) { + Logger.error(`[LibraryController] Invalid request. displayOrder must be a number`) return res.status(400).send('Invalid request. displayOrder must be a number') } updatePayload.displayOrder = req.body.displayOrder @@ -259,6 +261,13 @@ class LibraryController { const updatedSettings = { ...(req.library.settings || defaultLibrarySettings) } + // In case new settings are added in the future, ensure all settings are present + for (const key in defaultLibrarySettings) { + if (updatedSettings[key] === undefined) { + updatedSettings[key] = defaultLibrarySettings[key] + } + } + let hasUpdates = false let hasUpdatedDisableWatcher = false let hasUpdatedScanCron = false @@ -270,6 +279,7 @@ class LibraryController { if (key === 'metadataPrecedence') { if (!Array.isArray(req.body.settings[key])) { + Logger.error(`[LibraryController] Invalid request. Settings "metadataPrecedence" must be an array`) return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array') } if (JSON.stringify(req.body.settings[key]) !== JSON.stringify(updatedSettings[key])) { @@ -279,6 +289,7 @@ class LibraryController { } } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') { if (req.body.settings[key] !== null && typeof req.body.settings[key] !== 'string') { + Logger.error(`[LibraryController] Invalid request. Settings "${key}" must be a string`) return res.status(400).send(`Invalid request. Settings "${key}" must be a string`) } if (req.body.settings[key] !== updatedSettings[key]) { @@ -290,8 +301,10 @@ class LibraryController { } } else if (key === 'markAsFinishedPercentComplete') { if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`) return res.status(400).send(`Invalid request. Setting "${key}" must be a number`) } else if (req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be between 0 and 100`) return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`) } if (req.body.settings[key] !== updatedSettings[key]) { @@ -301,8 +314,10 @@ class LibraryController { } } else if (key === 'markAsFinishedTimeRemaining') { if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`) return res.status(400).send(`Invalid request. Setting "${key}" must be a number`) } else if (req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be greater than or equal to 0`) return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`) } if (req.body.settings[key] !== updatedSettings[key]) { @@ -312,6 +327,7 @@ class LibraryController { } } else { if (typeof req.body.settings[key] !== typeof updatedSettings[key]) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`) return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`) } if (req.body.settings[key] !== updatedSettings[key]) { @@ -353,6 +369,7 @@ class LibraryController { return false }) if (!success) { + Logger.error(`[LibraryController] Invalid folder directory "${path}"`) return res.status(400).send(`Invalid folder directory "${path}"`) } } From 8c8c4a15c3180bec169d0a3773a066eef45199ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A1lint=20Krist=C3=B3f?= Date: Mon, 28 Oct 2024 08:18:14 +0000 Subject: [PATCH 311/539] Translated using Weblate (Hungarian) Currently translated at 75.6% (810 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/ --- client/strings/hu.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hu.json b/client/strings/hu.json index 79852a9cd9..0590ab604c 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -66,6 +66,7 @@ "ButtonPurgeItemsCache": "Elemek gyorsítótárának törlése", "ButtonQueueAddItem": "Hozzáadás a sorhoz", "ButtonQueueRemoveItem": "Eltávolítás a sorból", + "ButtonQuickEmbed": "Gyors beágyazás", "ButtonQuickEmbedMetadata": "Metaadat gyors beágyazása", "ButtonQuickMatch": "Gyors egyeztetés", "ButtonReScan": "Újraszkennelés", From 94e2ea9df396e085cf5ba48d71d0a6379e209c89 Mon Sep 17 00:00:00 2001 From: Frantisek Nagy Date: Sat, 26 Oct 2024 20:55:42 +0000 Subject: [PATCH 312/539] Translated using Weblate (Hungarian) Currently translated at 75.6% (810 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/ --- client/strings/hu.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hu.json b/client/strings/hu.json index 0590ab604c..8ab6be9e99 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -344,7 +344,7 @@ "LabelHasSupplementaryEbook": "Van kiegészítő e-könyve", "LabelHideSubtitles": "Alcím elrejtése", "LabelHighestPriority": "Legmagasabb prioritás", - "LabelHost": "Házigazda", + "LabelHost": "Kiszolgáló", "LabelHour": "Óra", "LabelHours": "Órák", "LabelIcon": "Ikon", From 7ed711730e999eda9e88187f8282a4759c8410ac Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 27 Oct 2024 17:51:29 +0000 Subject: [PATCH 313/539] Translated using Weblate (Russian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 116 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index 04002ebae6..d27f138f8b 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -66,6 +66,7 @@ "ButtonPurgeItemsCache": "Очистить кэш элементов", "ButtonQueueAddItem": "Добавить в очередь", "ButtonQueueRemoveItem": "Удалить из очереди", + "ButtonQuickEmbed": "Быстрое внедрение", "ButtonQuickEmbedMetadata": "Быстрое встраивание метаданных", "ButtonQuickMatch": "Быстрый поиск", "ButtonReScan": "Пересканировать", @@ -162,6 +163,7 @@ "HeaderNotificationUpdate": "Уведомление об обновлении", "HeaderNotifications": "Уведомления", "HeaderOpenIDConnectAuthentication": "Аутентификация OpenID Connect", + "HeaderOpenListeningSessions": "Открытые сеансы прослушивания", "HeaderOpenRSSFeed": "Открыть RSS-канал", "HeaderOtherFiles": "Другие файлы", "HeaderPasswordAuthentication": "Аутентификация по паролю", @@ -179,6 +181,7 @@ "HeaderRemoveEpisodes": "Удалить {0} эпизодов", "HeaderSavedMediaProgress": "Прогресс медиа сохранен", "HeaderSchedule": "Планировщик", + "HeaderScheduleEpisodeDownloads": "Запланируйте автоматическую загрузку эпизодов", "HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки", "HeaderSession": "Сеансы", "HeaderSetBackupSchedule": "Установить планировщик бэкапов", @@ -224,7 +227,11 @@ "LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей", "LabelAllUsersIncludingGuests": "Все пользователи, включая гостей", "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", + "LabelApiToken": "Токен API", "LabelAppend": "Добавить", + "LabelAudioBitrate": "Битрейт аудио (напр. 128k)", + "LabelAudioChannels": "Аудиоканалы (1 или 2)", + "LabelAudioCodec": "Аудиокодек", "LabelAuthor": "Автор", "LabelAuthorFirstLast": "Автор (Имя Фамилия)", "LabelAuthorLastFirst": "Автор (Фамилия, Имя)", @@ -237,6 +244,7 @@ "LabelAutoRegister": "Автоматическая регистрация", "LabelAutoRegisterDescription": "Автоматическое создание новых пользователей после входа в систему", "LabelBackToUser": "Назад к пользователю", + "LabelBackupAudioFiles": "Резервное копирование аудиофайлов", "LabelBackupLocation": "Путь для бэкапов", "LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование", "LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups", @@ -245,15 +253,18 @@ "LabelBackupsNumberToKeep": "Сохранять бэкапов", "LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.", "LabelBitrate": "Битрейт", + "LabelBonus": "Бонус", "LabelBooks": "Книги", "LabelButtonText": "Текст кнопки", "LabelByAuthor": "{0}", "LabelChangePassword": "Изменить пароль", "LabelChannels": "Каналы", + "LabelChapterCount": "{0} Главы", "LabelChapterTitle": "Название главы", "LabelChapters": "Главы", "LabelChaptersFound": "глав найдено", "LabelClickForMoreInfo": "Нажмите, чтобы узнать больше", + "LabelClickToUseCurrentValue": "Нажмите, чтобы использовать текущее значение", "LabelClosePlayer": "Закрыть проигрыватель", "LabelCodec": "Кодек", "LabelCollapseSeries": "Свернуть серии", @@ -303,12 +314,25 @@ "LabelEmailSettingsTestAddress": "Тестовый адрес", "LabelEmbeddedCover": "Встроенная обложка", "LabelEnable": "Включить", + "LabelEncodingBackupLocation": "Резервная копия ваших оригинальных аудиофайлов будет сохранена в:", + "LabelEncodingChaptersNotEmbedded": "Главы не встраиваются в многодорожечные аудиокниги.", + "LabelEncodingClearItemCache": "Обязательно периодически очищайте кэш элементов.", + "LabelEncodingFinishedM4B": "Готовый M4B будет помещен в вашу папку с аудиокнигами по адресу:", + "LabelEncodingInfoEmbedded": "Метаданные будут встроены в звуковые дорожки внутри папки вашей аудиокниги.", + "LabelEncodingStartedNavigation": "Как только задача будет запущена, вы сможете перейти с этой страницы.", + "LabelEncodingTimeWarning": "Кодирование может занять до 30 минут.", + "LabelEncodingWarningAdvancedSettings": "Предупреждение: Не обновляйте эти настройки, если вы не знакомы с параметрами кодировки ffmpeg.", + "LabelEncodingWatcherDisabled": "Если у вас отключено наблюдение за папкой, вам нужно будет повторно пересканировать эту аудиокнигу.", "LabelEnd": "Конец", "LabelEndOfChapter": "Конец главы", "LabelEpisode": "Эпизод", + "LabelEpisodeNotLinkedToRssFeed": "Эпизод, не связанный с RSS-каналом", + "LabelEpisodeNumber": "Эпизод #{0}", "LabelEpisodeTitle": "Имя эпизода", "LabelEpisodeType": "Тип эпизода", + "LabelEpisodeUrlFromRssFeed": "URL-адрес эпизода из RSS-ленты", "LabelEpisodes": "Эпизодов", + "LabelEpisodic": "Эпизодический", "LabelExample": "Пример", "LabelExpandSeries": "Развернуть серию", "LabelExpandSubSeries": "Развернуть подсерию", @@ -336,6 +360,7 @@ "LabelFontScale": "Масштаб шрифта", "LabelFontStrikethrough": "Зачеркнутый", "LabelFormat": "Формат", + "LabelFull": "Полный", "LabelGenre": "Жанр", "LabelGenres": "Жанры", "LabelHardDeleteFile": "Жесткое удаление файла", @@ -391,6 +416,10 @@ "LabelLowestPriority": "Самый низкий приоритет", "LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по", "LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа", + "LabelMaxEpisodesToDownload": "Максимальное количество эпизодов для загрузки. Используйте 0 для неограниченного количества.", + "LabelMaxEpisodesToDownloadPerCheck": "Максимальное количество новых эпизодов для загрузки за одну проверку", + "LabelMaxEpisodesToKeep": "Максимальное количество сохраняемых эпизодов", + "LabelMaxEpisodesToKeepHelp": "Значение 0 не устанавливает максимального ограничения. После автоматической загрузки нового эпизода самый старый эпизод будет удален, если у вас более X эпизодов. При этом будет удален только 1 эпизод за каждую новую загрузку.", "LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaType": "Тип медиа", "LabelMetaTag": "Мета тег", @@ -436,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "Имя утверждения OpenID, содержащего список групп пользователя. Обычно их называют groups. Если эта настройка настроена, приложение будет автоматически назначать роли на основе членства пользователя в группах при условии, что эти группы названы в утверждении без учета регистра \"admin\", \"user\" или \"guest\". Утверждение должно содержать список, и если пользователь принадлежит к нескольким группам, то приложение назначит роль, соответствующую самому высокому уровню доступа. Если ни одна из групп не совпадает, доступ будет запрещен.", "LabelOpenRSSFeed": "Открыть RSS-канал", "LabelOverwrite": "Перезаписать", + "LabelPaginationPageXOfY": "Страница {0} из {1}", "LabelPassword": "Пароль", "LabelPath": "Путь", "LabelPermanent": "Постоянный", "LabelPermissionsAccessAllLibraries": "Есть доступ ко всем библиотекам", "LabelPermissionsAccessAllTags": "Есть доступ ко всем тегам", "LabelPermissionsAccessExplicitContent": "Есть доступ к явному содержимому", + "LabelPermissionsCreateEreader": "Можно создать читалку", "LabelPermissionsDelete": "Может удалять", "LabelPermissionsDownload": "Может скачивать", "LabelPermissionsUpdate": "Может обновлять", @@ -465,6 +496,8 @@ "LabelPubDate": "Дата публикации", "LabelPublishYear": "Год публикации", "LabelPublishedDate": "Опубликовано {0}", + "LabelPublishedDecade": "Опубликованное десятилетие", + "LabelPublishedDecades": "Опубликованные десятилетия", "LabelPublisher": "Издатель", "LabelPublishers": "Издатели", "LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца", @@ -484,21 +517,28 @@ "LabelRedo": "Повторить", "LabelRegion": "Регион", "LabelReleaseDate": "Дата выхода", + "LabelRemoveAllMetadataAbs": "Удалите все файлы metadata.abs", + "LabelRemoveAllMetadataJson": "Удалите все файлы metadata.json", "LabelRemoveCover": "Удалить обложку", + "LabelRemoveMetadataFile": "Удаление файлов метаданных в папках элементов библиотеки", + "LabelRemoveMetadataFileHelp": "Удалите все файлы metadata.json и metadata.abs из ваших папок {0}.", "LabelRowsPerPage": "Строк на странице", "LabelSearchTerm": "Поисковый запрос", "LabelSearchTitle": "Поиск по названию", "LabelSearchTitleOrASIN": "Поиск по названию или ASIN", "LabelSeason": "Сезон", + "LabelSeasonNumber": "Сезон #{0}", "LabelSelectAll": "Выбрать все", "LabelSelectAllEpisodes": "Выбрать все эпизоды", "LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа", "LabelSelectUsers": "Выбор пользователей", "LabelSendEbookToDevice": "Отправить e-книгу в...", "LabelSequence": "Последовательность", + "LabelSerial": "Серийный", "LabelSeries": "Серия", "LabelSeriesName": "Имя серии", "LabelSeriesProgress": "Прогресс серии", + "LabelServerLogLevel": "Уровень журнала сервера", "LabelServerYearReview": "Итоги года всего сервера ({0})", "LabelSetEbookAsPrimary": "Установить как основную", "LabelSetEbookAsSupplementary": "Установить как дополнительную", @@ -523,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Серии, в которых всего одна книга, будут скрыты со страницы серий и полок домашней страницы.", "LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице", "LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Процент выполнения больше, чем", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Оставшееся время составляет менее (секунд)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Отметьте мультимедийный элемент как законченный, когда", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропустить предыдущие книги в \"Продолжить серию\"", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "На домашней странице \"Продолжить серию\" отображается первая книга, не начатая в серии, в которой закончена хотя бы одна книга и нет начатых книг. При включении этого параметра серия будет продолжена с самой последней завершенной книги, а не с первой, которая не начата.", "LabelSettingsParseSubtitles": "Разбор подзаголовков", @@ -587,13 +630,15 @@ "LabelTimeDurationXMinutes": "{0} минут", "LabelTimeDurationXSeconds": "{0} секунд", "LabelTimeInMinutes": "Время в минутах", + "LabelTimeLeft": "{0} осталось", "LabelTimeListened": "Время прослушивания", "LabelTimeListenedToday": "Время прослушивания сегодня", "LabelTimeRemaining": "{0} осталось", - "LabelTimeToShift": "Время смещения в сек.", + "LabelTimeToShift": "Время смещения в секундах", "LabelTitle": "Название", "LabelToolsEmbedMetadata": "Встроить метаданные", "LabelToolsEmbedMetadataDescription": "Встроить метаданные в аудио файлы, включая обложку и главы.", + "LabelToolsM4bEncoder": "Кодировщик M4B", "LabelToolsMakeM4b": "Создать M4B файл аудиокниги", "LabelToolsMakeM4bDescription": "Создает .M4B файл аудиокниги с встроенными метаданными, обложкой и главами.", "LabelToolsSplitM4b": "Разделить M4B на MP3 файлы", @@ -606,6 +651,7 @@ "LabelTracksMultiTrack": "Мультитрек", "LabelTracksNone": "Нет треков", "LabelTracksSingleTrack": "Один трек", + "LabelTrailer": "Трейлер", "LabelType": "Тип", "LabelUnabridged": "Полное издание", "LabelUndo": "Отменить", @@ -619,8 +665,10 @@ "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", "LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", + "LabelUseAdvancedOptions": "Используйте расширенные опции", "LabelUseChapterTrack": "Показывать время главы", "LabelUseFullTrack": "Показывать время книги", + "LabelUseZeroForUnlimited": "Используйте 0 для неограниченного количества", "LabelUser": "Пользователь", "LabelUsername": "Имя пользователя", "LabelValue": "Значение", @@ -667,6 +715,7 @@ "MessageConfirmDeleteMetadataProvider": "Вы уверены, что хотите удалить пользовательский поставщик метаданных \"{0}\"?", "MessageConfirmDeleteNotification": "Вы уверены, что хотите удалить это уведомление?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", + "MessageConfirmEmbedMetadataInAudioFiles": "Вы уверены, что хотите вставить метаданные в {0} аудиофайлов?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?", "MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?", @@ -678,6 +727,7 @@ "MessageConfirmPurgeCache": "Очистка кэша удалит весь каталог в /metadata/cache.

Вы уверены, что хотите удалить каталог кэша?", "MessageConfirmPurgeItemsCache": "Очистка кэша элементов удалит весь каталог в /metadata/cache/items.
Вы уверены?", "MessageConfirmQuickEmbed": "Предупреждение! Быстрое встраивание не позволяет создавать резервные копии аудиофайлов. Убедитесь, что у вас есть резервная копия аудиофайлов.

Хотите продолжить?", + "MessageConfirmQuickMatchEpisodes": "При обнаружении совпадений информация о эпизодах быстрого поиска будет перезаписана. Будут обновлены только несопоставимые эпизоды. Вы уверены?", "MessageConfirmReScanLibraryItems": "Вы уверены, что хотите пересканировать {0} элементов?", "MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?", "MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?", @@ -685,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", "MessageConfirmRemoveListeningSessions": "Вы уверены, что хотите удалить {0} сеансов прослушивания?", + "MessageConfirmRemoveMetadataFiles": "Вы уверены, что хотите удалить все файлы metadata. {0} файлов из папок элементов вашей библиотеки?", "MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?", "MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?", "MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?", @@ -700,6 +751,7 @@ "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", "MessageEmbedFailed": "Вставка не удалась!", "MessageEmbedFinished": "Встраивание завершено!", + "MessageEmbedQueue": "Поставлен в очередь для внедрения метаданных ({0} в очереди)", "MessageEpisodesQueuedForDownload": "{0} Эпизод(ов) запланировано для закачки", "MessageEreaderDevices": "Чтобы обеспечить доставку электронных книг, вам может потребоваться добавить указанный выше адрес электронной почты в качестве действительного отправителя для каждого устройства, перечисленного ниже.", "MessageFeedURLWillBe": "URL канала будет {0}", @@ -744,6 +796,7 @@ "MessageNoLogs": "Нет логов", "MessageNoMediaProgress": "Нет прогресса медиа", "MessageNoNotifications": "Нет уведомлений", + "MessageNoPodcastFeed": "Недопустимый подкаст: Нет канала", "MessageNoPodcastsFound": "Подкасты не найдены", "MessageNoResults": "Нет результатов", "MessageNoSearchResultsFor": "Нет результатов поиска для \"{0}\"", @@ -760,6 +813,10 @@ "MessagePlaylistCreateFromCollection": "Создать плейлист из коллекции", "MessagePleaseWait": "Пожалуйста подождите...", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не имеет URL-адреса RSS-канала, который можно использовать для поиска", + "MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-канала", + "MessageQuickEmbedInProgress": "Быстрое внедрение в процессе выполнения", + "MessageQuickEmbedQueue": "Поставлен в очередь для быстрого внедрения ({0} в очереди)", + "MessageQuickMatchAllEpisodes": "Быстрое сопоставление всех эпизодов", "MessageQuickMatchDescription": "Заполняет пустые детали элемента и обложку первым результатом поиска из «{0}». Не перезаписывает сведения, если не включен параметр сервера 'Предпочитать метаданные поиска'.", "MessageRemoveChapter": "Удалить главу", "MessageRemoveEpisodes": "Удалить {0} эпизод(ов)", @@ -777,6 +834,41 @@ "MessageShareExpiresIn": "Срок действия истекает через {0}", "MessageShareURLWillBe": "URL-адрес общего доступа будет {0}", "MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?", + "MessageTaskAudioFileNotWritable": "Аудиофайл \"{0}\" недоступен для записи", + "MessageTaskCanceledByUser": "Задание отменено пользователем", + "MessageTaskDownloadingEpisodeDescription": "Загрузка эпизода \"{0}\"", + "MessageTaskEmbeddingMetadata": "Внедрение метаданных", + "MessageTaskEmbeddingMetadataDescription": "Встраивание метаданных в аудиокнигу \"{0}\"", + "MessageTaskEncodingM4b": "Кодировка M4B", + "MessageTaskEncodingM4bDescription": "Кодирование аудиокниги \"{0}\" в один файл формата m4b", + "MessageTaskFailed": "Неудачный", + "MessageTaskFailedToBackupAudioFile": "Не удалось создать резервную копию аудиофайла \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Не удалось создать каталог кэша", + "MessageTaskFailedToEmbedMetadataInFile": "Не удалось вставить метаданные в файл \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Не удалось объединить аудиофайлы", + "MessageTaskFailedToMoveM4bFile": "Не удалось переместить файл m4b", + "MessageTaskFailedToWriteMetadataFile": "Не удалось записать файл метаданных", + "MessageTaskMatchingBooksInLibrary": "Сопоставление книг в библиотеке \"{0}\"", + "MessageTaskNoFilesToScan": "Нет файлов для сканирования", + "MessageTaskOpmlImport": "Импорт OPML", + "MessageTaskOpmlImportDescription": "Создание подкастов из {0} RSS-каналов", + "MessageTaskOpmlImportFeed": "Канал импорта OPML", + "MessageTaskOpmlImportFeedDescription": "Импорт RSS-канала \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "Не удалось получить ленту подкаста", + "MessageTaskOpmlImportFeedPodcastDescription": "Создание подкаста \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "Подкаст уже существует по адресу", + "MessageTaskOpmlImportFeedPodcastFailed": "Не удалось создать подкаст", + "MessageTaskOpmlImportFinished": "Добавлено {0} подкастов", + "MessageTaskOpmlParseFailed": "Не удалось разобрать OPML-файл", + "MessageTaskOpmlParseFastFail": "Недопустимый тег файла OPML не найден ИЛИ тег не найден", + "MessageTaskOpmlParseNoneFound": "В OPML-файле не найдено ни одного канала", + "MessageTaskScanItemsAdded": "{0} добавлено", + "MessageTaskScanItemsMissing": "{0} отсутствует", + "MessageTaskScanItemsUpdated": "{0} обновлено", + "MessageTaskScanNoChangesNeeded": "Никаких изменений не требуется", + "MessageTaskScanningFileChanges": "Проверка изменений файлов в \"{0}\"", + "MessageTaskScanningLibrary": "Сканирование библиотеки \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "Целевой каталог недоступен для записи", "MessageThinking": "Думаю...", "MessageUploaderItemFailed": "Не удалось загрузить", "MessageUploaderItemSuccess": "Успешно загружено!", @@ -794,6 +886,10 @@ "NoteUploaderFoldersWithMediaFiles": "Папки с медиафайлами будут обрабатываться как отдельные элементы библиотеки.", "NoteUploaderOnlyAudioFiles": "Если загружать только аудиофайлы, то каждый аудиофайл будет обрабатываться как отдельная аудиокнига.", "NoteUploaderUnsupportedFiles": "Неподдерживаемые файлы игнорируются. При выборе или удалении папки другие файлы, не находящиеся в папке элемента, игнорируются.", + "NotificationOnBackupCompletedDescription": "Запускается при завершении резервного копирования", + "NotificationOnBackupFailedDescription": "Срабатывает при сбое резервного копирования", + "NotificationOnEpisodeDownloadedDescription": "Запускается при автоматической загрузке эпизода подкаста", + "NotificationOnTestDescription": "Событие для тестирования системы оповещения", "PlaceholderNewCollection": "Новое имя коллекции", "PlaceholderNewFolderPath": "Путь к новой папке", "PlaceholderNewPlaylist": "Новое название плейлиста", @@ -819,6 +915,7 @@ "StatsYearInReview": "ИТОГИ ГОДА", "ToastAccountUpdateSuccess": "Учетная запись обновлена", "ToastAppriseUrlRequired": "Необходимо ввести URL-адрес Apprise", + "ToastAsinRequired": "Требуется ASIN", "ToastAuthorImageRemoveSuccess": "Изображение автора удалено", "ToastAuthorNotFound": "Автор \"{0}\" не найден", "ToastAuthorRemoveSuccess": "Автор удален", @@ -838,6 +935,8 @@ "ToastBackupUploadSuccess": "Бэкап загружен", "ToastBatchDeleteFailed": "Не удалось выполнить пакетное удаление", "ToastBatchDeleteSuccess": "Успешное пакетное удаление", + "ToastBatchQuickMatchFailed": "Не удалось выполнить пакетное быстрое сопоставление!", + "ToastBatchQuickMatchStarted": "Начато пакетное быстрое сопоставление {0} книг!", "ToastBatchUpdateFailed": "Сбой пакетного обновления", "ToastBatchUpdateSuccess": "Успешное пакетное обновление", "ToastBookmarkCreateFailed": "Не удалось создать закладку", @@ -849,6 +948,7 @@ "ToastChaptersHaveErrors": "Главы имеют ошибки", "ToastChaptersMustHaveTitles": "Главы должны содержать названия", "ToastChaptersRemoved": "Удалены главы", + "ToastChaptersUpdated": "Обновленные главы", "ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию", "ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию", "ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции", @@ -866,10 +966,14 @@ "ToastEncodeCancelSucces": "Кодирование отменено", "ToastEpisodeDownloadQueueClearFailed": "Не удалось очистить очередь", "ToastEpisodeDownloadQueueClearSuccess": "Очередь загрузки эпизода очищена", + "ToastEpisodeUpdateSuccess": "{0 эпизодов обновлено", "ToastErrorCannotShare": "Невозможно предоставить общий доступ на этом устройстве", "ToastFailedToLoadData": "Не удалось загрузить данные", + "ToastFailedToMatch": "Не удалось найти совпадения", "ToastFailedToShare": "Не удалось поделиться", + "ToastFailedToUpdate": "Не удалось обновить", "ToastInvalidImageUrl": "Неверный URL изображения", + "ToastInvalidMaxEpisodesToDownload": "Недопустимое максимальное количество загружаемых эпизодов", "ToastInvalidUrl": "Неверный URL", "ToastItemCoverUpdateSuccess": "Обложка элемента обновлена", "ToastItemDeletedFailed": "Не удалось удалить элемент", @@ -887,14 +991,22 @@ "ToastLibraryScanFailedToStart": "Не удалось запустить сканирование", "ToastLibraryScanStarted": "Запущено сканирование библиотеки", "ToastLibraryUpdateSuccess": "Библиотека \"{0}\" обновлена", + "ToastMatchAllAuthorsFailed": "Не удалось найти совпадения со всеми авторами", + "ToastMetadataFilesRemovedError": "Ошибка при удалении файлов metadata.{0}", + "ToastMetadataFilesRemovedNoneFound": "В библиотеке не найдено файлов metadata.{0}", + "ToastMetadataFilesRemovedNoneRemoved": "Нет удаленных файлов metadata.{0}", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлов удалено", + "ToastMustHaveAtLeastOnePath": "Должен быть хотя бы один путь", "ToastNameEmailRequired": "Имя и адрес электронной почты обязательны", "ToastNameRequired": "Имя обязательно для заполнения", + "ToastNewEpisodesFound": "{0} новых эпизодов найдено", "ToastNewUserCreatedFailed": "Не удалось создать учетную запись: \"{0}\"", "ToastNewUserCreatedSuccess": "Новая учетная запись создана", "ToastNewUserLibraryError": "Необходимо выбрать хотя бы одну библиотеку", "ToastNewUserPasswordError": "Должен иметь пароль, только пользователь root может иметь пустой пароль", "ToastNewUserTagError": "Необходимо выбрать хотя бы один тег", "ToastNewUserUsernameError": "Введите имя пользователя", + "ToastNoNewEpisodesFound": "Новых эпизодов не найдено", "ToastNoUpdatesNecessary": "Обновления не требуются", "ToastNotificationCreateFailed": "Не удалось создать уведомление", "ToastNotificationDeleteFailed": "Не удалось удалить уведомление", @@ -913,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "Не удалось получить ленту подкастов", "ToastPodcastNoEpisodesInFeed": "В RSS-ленте эпизодов не найдено", "ToastPodcastNoRssFeed": "В подкасте нет RSS-канала", + "ToastProgressIsNotBeingSynced": "Прогресс не синхронизируется, перезапустите воспроизведение", "ToastProviderCreatedFailed": "Не удалось добавить провайдера", "ToastProviderCreatedSuccess": "Добавлен новый провайдер", "ToastProviderNameAndUrlRequired": "Имя и URL обязательные", @@ -939,6 +1052,7 @@ "ToastSessionCloseFailed": "Не удалось закрыть сеанс", "ToastSessionDeleteFailed": "Не удалось удалить сеанс", "ToastSessionDeleteSuccess": "Сеанс удален", + "ToastSleepTimerDone": "Выполнен таймер сна... Хр-р-р-р", "ToastSlugMustChange": "Slug содержит недопустимые символы", "ToastSlugRequired": "Требуется Slug", "ToastSocketConnected": "Сокет подключен", From f83f4d41f1c4969d9a7fe576265a419c9da10e1e Mon Sep 17 00:00:00 2001 From: thehijacker Date: Sun, 27 Oct 2024 12:56:04 +0000 Subject: [PATCH 314/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index d07696f00d..b4e4383fd0 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Lahko dostopa do vseh knjižnic", "LabelPermissionsAccessAllTags": "Lahko dostopa do vseh oznak", "LabelPermissionsAccessExplicitContent": "Lahko dostopa do eksplicitne vsebine", + "LabelPermissionsCreateEreader": "Lahko ustvari e-bralnik", "LabelPermissionsDelete": "Lahko briše", "LabelPermissionsDownload": "Lahko prenaša", "LabelPermissionsUpdate": "Lahko posodablja", From d986673dfd761fcd3124e20524d91163fd789869 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:41:21 +0000 Subject: [PATCH 315/539] Translated using Weblate (German) Currently translated at 99.8% (1069 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index f0c167377d..a427c2885b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -253,6 +253,7 @@ "LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen", "LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn du bereits mehrere Sicherungen als die definierte max. Anzahl hast, solltest du diese manuell entfernen.", "LabelBitrate": "Bitrate", + "LabelBonus": "Bonus", "LabelBooks": "Bücher", "LabelButtonText": "Knopftext", "LabelByAuthor": "von {0}", @@ -331,6 +332,7 @@ "LabelEpisodeType": "Episodentyp", "LabelEpisodeUrlFromRssFeed": "Episoden URL vom RSS-Feed", "LabelEpisodes": "Episoden", + "LabelEpisodic": "Episodisch", "LabelExample": "Beispiel", "LabelExpandSeries": "Serie ausklappen", "LabelExpandSubSeries": "Unterserie ausklappen", @@ -470,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken", "LabelPermissionsAccessAllTags": "Zugriff auf alle Schlagwörter", "LabelPermissionsAccessExplicitContent": "Zugriff auf explizite (alterbeschränkte) Inhalte", + "LabelPermissionsCreateEreader": "Kann E-Reader erstellen", "LabelPermissionsDelete": "Darf Löschen", "LabelPermissionsDownload": "Herunterladen", "LabelPermissionsUpdate": "Aktualisieren", @@ -559,6 +562,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Serien, die nur ein einzelnes Buch enthalten, werden auf der Startseite und in der Serienansicht ausgeblendet.", "LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht", "LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "In Prozent gehört größer als", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Verbleibende Zeit ist weniger als (Sekunden)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Markiere Mediendateien als fertig, wenn", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Überspringe vorherige Bücher in fortführender Serie", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Die Startseite von \"Fortführende Serien\" zeigt das erste noch nicht begonnene Buch in Serien an, die mindestens ein Buch abgeschlossen und keine Bücher begonnen haben. Wenn diese Einstellung aktiviert wird, werden Serien ab dem letzten abgeschlossenen Buch fortgesetzt und nicht ab dem ersten nicht begonnenen Buch.", "LabelSettingsParseSubtitles": "Analysiere Untertitel", @@ -644,6 +650,7 @@ "LabelTracksMultiTrack": "Mehrfachdatei", "LabelTracksNone": "Keine Dateien", "LabelTracksSingleTrack": "Einzeldatei", + "LabelTrailer": "Vorschau", "LabelType": "Typ", "LabelUnabridged": "Ungekürzt", "LabelUndo": "Rückgängig machen", @@ -719,6 +726,7 @@ "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter /metadata/cache/items gelöscht.
Bist du dir sicher?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt.

Möchtest du fortfahren?", + "MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", From 399c40debd50ec47bbb7ef034478ae750fae7001 Mon Sep 17 00:00:00 2001 From: biuklija Date: Mon, 28 Oct 2024 19:42:08 +0000 Subject: [PATCH 316/539] Translated using Weblate (Croatian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 54a7ce02db..d7d0fde54e 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Ima pristup svim knjižnicama", "LabelPermissionsAccessAllTags": "Ima pristup svim oznakama", "LabelPermissionsAccessExplicitContent": "Ima pristup eksplicitnom sadržaju", + "LabelPermissionsCreateEreader": "Može stvoriti e-čitač", "LabelPermissionsDelete": "Smije brisati", "LabelPermissionsDownload": "Smije preuzimati", "LabelPermissionsUpdate": "Smije ažurirati", @@ -562,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Serijali koji se sastoje od samo jedne knjige neće se prikazivati na stranici serijala i na policama početne stranice.", "LabelSettingsHomePageBookshelfView": "Prikaži početnu stranicu kao policu s knjigama", "LabelSettingsLibraryBookshelfView": "Prikaži knjižnicu kao policu s knjigama", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Postotak dovršenosti veći od", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", "LabelSettingsParseSubtitles": "Raščlani podnaslove", From d40086fea1e80befe9cdc6b2cf3929872c76bbf3 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Mon, 28 Oct 2024 11:48:51 +0000 Subject: [PATCH 317/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 1754bf5642..2830a7101a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllTags": "可以访问所有标签", "LabelPermissionsAccessExplicitContent": "可以访问显式内容", + "LabelPermissionsCreateEreader": "可以创建电子阅读器", "LabelPermissionsDelete": "可以删除", "LabelPermissionsDownload": "可以下载", "LabelPermissionsUpdate": "可以更新", From 50fd659749128a321ecc3284e128d014433145a9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 28 Oct 2024 17:05:47 -0500 Subject: [PATCH 318/539] Version bump v2.16.1 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index e82b551440..f69dab4d78 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.16.0", + "version": "2.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.16.0", + "version": "2.16.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 441af9afd8..703d7f661c 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.16.0", + "version": "2.16.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 15c1622141..189781ba9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.16.0", + "version": "2.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.16.0", + "version": "2.16.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 723cafe066..09c7971156 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.16.0", + "version": "2.16.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 524cf5ec5b8f97a44b778dfed70e11efc2b35c9f Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 29 Oct 2024 21:42:44 +0200 Subject: [PATCH 319/539] Fix incorrect call to handleDownloadError --- server/controllers/LibraryItemController.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 0b4d3d0cd2..a51a6e062e 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -115,7 +115,7 @@ class LibraryItemController { res.sendStatus(200) } - #handleDownloadError(error, res) { + static handleDownloadError(error, res) { if (!res.headersSent) { if (error.code === 'ENOENT') { return res.status(404).send('File not found') @@ -158,7 +158,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`) } catch (error) { Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } @@ -865,7 +865,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`) } catch (error) { Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } @@ -909,7 +909,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded ebook file "${ebookFilePath}"`) } catch (error) { Logger.error(`[LibraryItemController] Failed to download ebook file "${ebookFilePath}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } From 6eba467b91af8a65b47e683509294183b0aa891e Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 29 Oct 2024 15:41:31 -0500 Subject: [PATCH 320/539] Fix:Session sync for streaming podcast episodes using incorrect duration #3560 --- server/managers/PlaybackSessionManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 33a3ccd238..ce43fc8c41 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -366,7 +366,7 @@ class PlaybackSessionManager { libraryItemId: libraryItem.id, episodeId: session.episodeId, // duration no longer required (v2.15.1) but used if available - duration: syncData.duration || libraryItem.media.duration || 0, + duration: syncData.duration || session.duration || 0, currentTime: syncData.currentTime, progress: session.progress, markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining, From c69e97ea241b30c2cbbaaee993ae4948fc3b2a66 Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 28 Oct 2024 23:05:33 +0000 Subject: [PATCH 321/539] Translated using Weblate (French) Currently translated at 95.7% (1025 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index 3674acc303..d31c59714c 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -250,11 +250,13 @@ "LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver", "LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.", "LabelBitrate": "Débit binaire", + "LabelBonus": "Bonus", "LabelBooks": "Livres", "LabelButtonText": "Texte du bouton", "LabelByAuthor": "par {0}", "LabelChangePassword": "Modifier le mot de passe", "LabelChannels": "Canaux", + "LabelChapterCount": "{0} Chapitres", "LabelChapterTitle": "Titre du chapitre", "LabelChapters": "Chapitres", "LabelChaptersFound": "chapitres trouvés", From e05cb0ef4de000eb20e46b6557a35fa12cc6b9a0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 29 Oct 2024 16:11:36 -0500 Subject: [PATCH 322/539] Version bump v2.16.2 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index f69dab4d78..f31266cbf2 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 703d7f661c..2feb833b25 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 189781ba9a..6e3276cea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 09c7971156..d31f202202 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 63fdf0d18e9efceab3e6442ff6b50a1a64bdae9a Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Tue, 29 Oct 2024 18:22:38 -0700 Subject: [PATCH 323/539] Update: user directive in docker compose file --- docker-compose.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 68e012fb87..b8d428a233 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,4 @@ ### EXAMPLE DOCKER COMPOSE ### -version: "3.7" - services: audiobookshelf: image: ghcr.io/advplyr/audiobookshelf:latest @@ -23,8 +21,7 @@ services: # you are running ABS on - ./config:/config restart: unless-stopped - # You can use the following environment variable to run the ABS + # You can use the following user directive to run the ABS # docker container as a specific user. You will need to change # the UID and GID to the correct values for your user. - #environment: - # - user=1000:1000 + # user: 1000:1000 From e0c66ea6dfa86f4a213864532999a703e99feeab Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 30 Oct 2024 15:27:18 -0500 Subject: [PATCH 324/539] Fix:Global search unclickable from trackpad due to blur event closing menu --- client/components/controls/GlobalSearch.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/controls/GlobalSearch.vue b/client/components/controls/GlobalSearch.vue index cb46895a59..58796fded1 100644 --- a/client/components/controls/GlobalSearch.vue +++ b/client/components/controls/GlobalSearch.vue @@ -9,7 +9,7 @@ close
-
+
  • {{ $strings.MessageThinking }}

    @@ -157,7 +157,7 @@ export default { clearTimeout(this.focusTimeout) this.focusTimeout = setTimeout(() => { this.showMenu = false - }, 200) + }, 100) }, async runSearch(value) { this.lastSearch = value From 32105665c19187a9360311388da7eb15bb8fdf7b Mon Sep 17 00:00:00 2001 From: Achim Date: Thu, 31 Oct 2024 15:29:40 +0100 Subject: [PATCH 325/539] 'mpg' and 'mpeg' added as supported audio-type/file-extension --- server/utils/globals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/globals.js b/server/utils/globals.js index 877cf07a08..5a5bd9513e 100644 --- a/server/utils/globals.js +++ b/server/utils/globals.js @@ -1,6 +1,6 @@ const globals = { SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], - SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], + SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpg', 'mpeg'], SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], TextFileTypes: ['txt', 'nfo'], MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] From ae9efe63596493be6413650586725b881a24e6d2 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Thu, 31 Oct 2024 15:30:51 +0000 Subject: [PATCH 326/539] Add keyboard focus to MultiSelectQueryInput edit and close --- client/components/ui/MultiSelectQueryInput.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index 099ee70995..6b33acf3c4 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -5,9 +5,9 @@
    -
    - edit - close +
    + edit + close
    {{ item[textKey] }}
    @@ -65,6 +65,7 @@ export default { currentSearch: null, typingTimeout: null, isFocused: false, + inputFocused: false, menu: null, items: [] } @@ -114,6 +115,9 @@ export default { getIsSelected(itemValue) { return !!this.selected.find((i) => i.id === itemValue) }, + setInputFocused(focused) { + this.inputFocused = focused + }, search() { if (!this.textInput) return this.currentSearch = this.textInput From e55db0afdca634b4a0b9e4fafbb9bdf519c6b029 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Thu, 31 Oct 2024 15:44:19 +0000 Subject: [PATCH 327/539] Add focus and enter key support to the add button in MultiSelectQueryInput --- client/components/ui/MultiSelectQueryInput.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index 6b33acf3c4..d0bdcef22e 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -12,7 +12,7 @@ {{ item[textKey] }}
    - add + add
    From a0b3960ee416ffd641e257b487171cc3a54817ab Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Thu, 31 Oct 2024 16:29:48 +0000 Subject: [PATCH 328/539] Fix enter key and focus for edit modal --- client/components/ui/MultiSelectQueryInput.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index d0bdcef22e..fe7187ee21 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -6,7 +6,7 @@
    - edit + edit close
    {{ item[textKey] }} From f3d2b781ab38569e52000815079957252195bb4a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 1 Nov 2024 09:12:40 -0500 Subject: [PATCH 329/539] Add mime types for MPEG/MPG --- client/players/LocalAudioPlayer.js | 11 +++++------ client/plugins/constants.js | 8 +++----- server/utils/constants.js | 4 +++- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/client/players/LocalAudioPlayer.js b/client/players/LocalAudioPlayer.js index eb1484bb6c..7fc17e7aae 100644 --- a/client/players/LocalAudioPlayer.js +++ b/client/players/LocalAudioPlayer.js @@ -147,7 +147,7 @@ export default class LocalAudioPlayer extends EventEmitter { timeoutRetry: { maxNumRetry: 4, retryDelayMs: 0, - maxRetryDelayMs: 0, + maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 8, @@ -160,7 +160,7 @@ export default class LocalAudioPlayer extends EventEmitter { } return retry } - }, + } } } } @@ -194,7 +194,7 @@ export default class LocalAudioPlayer extends EventEmitter { setDirectPlay() { // Set initial track and track time offset - var trackIndex = this.audioTracks.findIndex(t => this.startTime >= t.startOffset && this.startTime < (t.startOffset + t.duration)) + var trackIndex = this.audioTracks.findIndex((t) => this.startTime >= t.startOffset && this.startTime < t.startOffset + t.duration) this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0 this.loadCurrentTrack() @@ -270,7 +270,7 @@ export default class LocalAudioPlayer extends EventEmitter { // Seeking Direct play if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) { // Change Track - var trackIndex = this.audioTracks.findIndex(t => time >= t.startOffset && time < (t.startOffset + t.duration)) + var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration) if (trackIndex >= 0) { this.startTime = time this.currentTrackIndex = trackIndex @@ -293,7 +293,6 @@ export default class LocalAudioPlayer extends EventEmitter { this.player.volume = volume } - // Utils isValidDuration(duration) { if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) { @@ -338,4 +337,4 @@ export default class LocalAudioPlayer extends EventEmitter { var last = bufferedRanges[bufferedRanges.length - 1] return last.end } -} \ No newline at end of file +} diff --git a/client/plugins/constants.js b/client/plugins/constants.js index d89fbbbd6b..90c40b8c44 100644 --- a/client/plugins/constants.js +++ b/client/plugins/constants.js @@ -1,6 +1,6 @@ const SupportedFileTypes = { image: ['png', 'jpg', 'jpeg', 'webp'], - audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], + audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpeg', 'mpg'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], info: ['nfo'], text: ['txt'], @@ -81,11 +81,9 @@ const Hotkeys = { } } -export { - Constants -} +export { Constants } export default ({ app }, inject) => { inject('constants', Constants) inject('keynames', KeyNames) inject('hotkeys', Hotkeys) -} \ No newline at end of file +} diff --git a/server/utils/constants.js b/server/utils/constants.js index cbfe65f207..dd52e2e1b2 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -49,5 +49,7 @@ module.exports.AudioMimeType = { WEBMA: 'audio/webm', MKA: 'audio/x-matroska', AWB: 'audio/amr-wb', - CAF: 'audio/x-caf' + CAF: 'audio/x-caf', + MPEG: 'audio/mpeg', + MPG: 'audio/mpeg' } From 431ae97593da6930e5a9be0bed126f5afc8e135d Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 09:02:23 +0200 Subject: [PATCH 330/539] add Database.getLibraryItemCoverPath --- server/Database.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/Database.js b/server/Database.js index 9bce260503..9b9f9cf987 100644 --- a/server/Database.js +++ b/server/Database.js @@ -808,6 +808,28 @@ class Database { return `${normalizedColumn} LIKE ${pattern}` } } + + async getLibraryItemCoverPath(libraryItemId) { + const libraryItem = await this.libraryItemModel.findByPk(libraryItemId, { + attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], + include: [ + { + model: this.bookModel, + attributes: ['id', 'coverPath'] + }, + { + model: this.podcastModel, + attributes: ['id', 'coverPath'] + } + ] + }) + if (!libraryItem) { + Logger.warn(`[Database] getCover: Library item "${libraryItemId}" does not exist`) + return null + } + + return libraryItem.media.coverPath + } } module.exports = new Database() From 9e990d79272f05ef79415d5b94835787d25200cd Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 09:05:30 +0200 Subject: [PATCH 331/539] Optimize LibraryItemController.getCover --- server/controllers/LibraryItemController.js | 41 ++++++--------------- server/managers/CacheManager.js | 19 +++++++--- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 0b4d3d0cd2..f0f5c86d84 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -342,44 +342,25 @@ class LibraryItemController { query: { width, height, format, raw } } = req - const libraryItem = await Database.libraryItemModel.findByPk(req.params.id, { - attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], - include: [ - { - model: Database.bookModel, - attributes: ['id', 'coverPath', 'tags', 'explicit'] - }, - { - model: Database.podcastModel, - attributes: ['id', 'coverPath', 'tags', 'explicit'] - } - ] - }) - if (!libraryItem) { - Logger.warn(`[LibraryItemController] getCover: Library item "${req.params.id}" does not exist`) - return res.sendStatus(404) - } - - // Check if user can access this library item - if (!req.user.checkCanAccessLibraryItem(libraryItem)) { - return res.sendStatus(403) - } + if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400') - // Check if library item media has a cover path - if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) { - return res.sendStatus(404) + const libraryItemId = req.params.id + if (!libraryItemId) { + return res.sendStatus(400) } - if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400') - if (raw) { + const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + if (!coverPath || !(await fs.pathExists(coverPath))) { + return res.sendStatus(404) + } // any value if (global.XAccel) { - const encodedURI = encodeUriPath(global.XAccel + libraryItem.media.coverPath) + const encodedURI = encodeUriPath(global.XAccel + coverPath) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() } - return res.sendFile(libraryItem.media.coverPath) + return res.sendFile(coverPath) } const options = { @@ -387,7 +368,7 @@ class LibraryItemController { height: height ? parseInt(height) : null, width: width ? parseInt(width) : null } - return CacheManager.handleCoverCache(res, libraryItem.id, libraryItem.media.coverPath, options) + return CacheManager.handleCoverCache(res, libraryItemId, options) } /** diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index b4d2f270c2..d1b274238e 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -4,6 +4,7 @@ const stream = require('stream') const Logger = require('../Logger') const { resizeImage } = require('../utils/ffmpegHelpers') const { encodeUriPath } = require('../utils/fileUtils') +const Database = require('../Database') class CacheManager { constructor() { @@ -29,24 +30,24 @@ class CacheManager { await fs.ensureDir(this.ItemCachePath) } - async handleCoverCache(res, libraryItemId, coverPath, options = {}) { + async handleCoverCache(res, libraryItemId, options = {}) { const format = options.format || 'webp' const width = options.width || 400 const height = options.height || null res.type(`image/${format}`) - const path = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format + const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format // Cache exists - if (await fs.pathExists(path)) { + if (await fs.pathExists(cachePath)) { if (global.XAccel) { - const encodedURI = encodeUriPath(global.XAccel + path) + const encodedURI = encodeUriPath(global.XAccel + cachePath) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() } - const r = fs.createReadStream(path) + const r = fs.createReadStream(cachePath) const ps = new stream.PassThrough() stream.pipeline(r, ps, (err) => { if (err) { @@ -57,7 +58,13 @@ class CacheManager { return ps.pipe(res) } - const writtenFile = await resizeImage(coverPath, path, width, height) + // Cached cover does not exist, generate it + const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + if (!coverPath || !(await fs.pathExists(coverPath))) { + return res.sendStatus(404) + } + + const writtenFile = await resizeImage(coverPath, cachePath, width, height) if (!writtenFile) return res.sendStatus(500) if (global.XAccel) { From 4224b8a486b106d8c26185a5f24253a0d4587975 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 15:17:11 +0200 Subject: [PATCH 332/539] No auth and req.user for cover images --- server/Auth.js | 20 ++++++++++++++++++++ server/Server.js | 14 +++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 60af2a1e05..6e5a46212f 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -18,6 +18,26 @@ class Auth { constructor() { // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() + this.ignorePattern = /\/api\/items\/[^/]+\/cover/ + } + + /** + * Checks if the request should not be authenticated. + * @param {import('express').Request} req + * @returns {boolean} + * @private + */ + authNotNeeded(req) { + return req.method === 'GET' && this.ignorePattern.test(req.originalUrl) + } + + ifAuthNeeded(middleware) { + return (req, res, next) => { + if (this.authNotNeeded(req)) { + return next() + } + middleware(req, res, next) + } } /** diff --git a/server/Server.js b/server/Server.js index d82652376a..58a2079ef5 100644 --- a/server/Server.js +++ b/server/Server.js @@ -238,7 +238,7 @@ class Server { // init passport.js app.use(passport.initialize()) // register passport in express-session - app.use(passport.session()) + app.use(this.auth.ifAuthNeeded(passport.session())) // config passport.js await this.auth.initPassportJs() @@ -268,6 +268,10 @@ class Server { router.use(express.urlencoded({ extended: true, limit: '5mb' })) router.use(express.json({ limit: '5mb' })) + router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) + router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) + router.use('/public', this.publicRouter.router) + // Static path to generated nuxt const distPath = Path.join(global.appRoot, '/client/dist') router.use(express.static(distPath)) @@ -275,10 +279,6 @@ class Server { // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) - router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) - router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) - router.use('/public', this.publicRouter.router) - // RSS Feed temp route router.get('/feed/:slug', (req, res) => { Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) @@ -296,7 +296,7 @@ class Server { await this.auth.initAuthRoutes(router) // Client dynamic routes - const dyanimicRoutes = [ + const dynamicRoutes = [ '/item/:id', '/author/:id', '/audiobook/:id/chapters', @@ -319,7 +319,7 @@ class Server { '/playlist/:id', '/share/:slug' ] - dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) + dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) router.post('/init', (req, res) => { if (Database.hasRootUser) { From c25acb41fa9a6302e9d3e1261c5da38ef4a7a499 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 15:37:14 +0200 Subject: [PATCH 333/539] Remove token from cover image urls --- client/store/globals.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/store/globals.js b/client/store/globals.js index c0e7d78854..65878fb445 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -98,7 +98,7 @@ export const getters = { const userToken = rootGetters['user/getToken'] const lastUpdate = libraryItem.updatedAt || Date.now() const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers - return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}` + return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?ts=${lastUpdate}${raw ? '&raw=1' : ''}` }, getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => @@ -106,7 +106,7 @@ export const getters = { const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` if (!libraryItemId) return placeholder const userToken = rootGetters['user/getToken'] - return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}` + return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}` }, getIsBatchSelectingMediaItems: (state) => { return state.selectedMediaItems.length From 7a1623e6a11307842060360bac97ee10256548f9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 2 Nov 2024 12:56:40 -0500 Subject: [PATCH 334/539] Move cover path func to LibraryItem model --- server/Auth.js | 2 +- server/Database.js | 22 ----------------- server/controllers/LibraryItemController.js | 2 +- server/managers/CacheManager.js | 2 +- server/models/LibraryItem.js | 27 +++++++++++++++++++++ 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 6e5a46212f..da124b72a9 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -23,7 +23,7 @@ class Auth { /** * Checks if the request should not be authenticated. - * @param {import('express').Request} req + * @param {Request} req * @returns {boolean} * @private */ diff --git a/server/Database.js b/server/Database.js index 9b9f9cf987..9bce260503 100644 --- a/server/Database.js +++ b/server/Database.js @@ -808,28 +808,6 @@ class Database { return `${normalizedColumn} LIKE ${pattern}` } } - - async getLibraryItemCoverPath(libraryItemId) { - const libraryItem = await this.libraryItemModel.findByPk(libraryItemId, { - attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], - include: [ - { - model: this.bookModel, - attributes: ['id', 'coverPath'] - }, - { - model: this.podcastModel, - attributes: ['id', 'coverPath'] - } - ] - }) - if (!libraryItem) { - Logger.warn(`[Database] getCover: Library item "${libraryItemId}" does not exist`) - return null - } - - return libraryItem.media.coverPath - } } module.exports = new Database() diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index f0f5c86d84..1976c34df9 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -350,7 +350,7 @@ class LibraryItemController { } if (raw) { - const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId) if (!coverPath || !(await fs.pathExists(coverPath))) { return res.sendStatus(404) } diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index d1b274238e..83efae9075 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -59,7 +59,7 @@ class CacheManager { } // Cached cover does not exist, generate it - const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId) if (!coverPath || !(await fs.pathExists(coverPath))) { return res.sendStatus(404) } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index dd07747a91..9815b2169a 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -863,6 +863,33 @@ class LibraryItem extends Model { return this.getOldLibraryItem(libraryItem) } + /** + * + * @param {string} libraryItemId + * @returns {Promise} + */ + static async getCoverPath(libraryItemId) { + const libraryItem = await this.findByPk(libraryItemId, { + attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], + include: [ + { + model: this.bookModel, + attributes: ['id', 'coverPath'] + }, + { + model: this.podcastModel, + attributes: ['id', 'coverPath'] + } + ] + }) + if (!libraryItem) { + Logger.warn(`[LibraryItem] getCoverPath: Library item "${libraryItemId}" does not exist`) + return null + } + + return libraryItem.media.coverPath + } + /** * * @param {import('sequelize').FindOptions} options From 7a49681dd205ec36a8b083cdaee1cf8e6c78d1e2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 2 Nov 2024 13:02:40 -0500 Subject: [PATCH 335/539] Fix includes --- server/models/LibraryItem.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 9815b2169a..17c3b12586 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -873,11 +873,11 @@ class LibraryItem extends Model { attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], include: [ { - model: this.bookModel, + model: this.sequelize.models.book, attributes: ['id', 'coverPath'] }, { - model: this.podcastModel, + model: this.sequelize.models.podcast, attributes: ['id', 'coverPath'] } ] From 3bc29414456b60d0653d32b9a3fc72c88c3b2400 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:44:57 +0200 Subject: [PATCH 336/539] No db access for author image if in disk cache --- server/controllers/AuthorController.js | 21 ++++++++++++++------- server/managers/CacheManager.js | 17 +++++++++++------ server/routers/ApiRouter.js | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 54a6418563..45bbdf84bd 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -381,16 +381,23 @@ class AuthorController { */ async getImage(req, res) { const { - query: { width, height, format, raw }, - author + query: { width, height, format, raw } } = req - if (!author.imagePath || !(await fs.pathExists(author.imagePath))) { - Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`) - return res.sendStatus(404) - } + const authorId = req.params.id if (raw) { + const author = await Database.authorModel.findByPk(authorId) + if (!author) { + Logger.warn(`[AuthorController] Author "${authorId}" not found`) + return res.sendStatus(404) + } + + if (!author.imagePath || !(await fs.pathExists(author.imagePath))) { + Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`) + return res.sendStatus(404) + } + return res.sendFile(author.imagePath) } @@ -399,7 +406,7 @@ class AuthorController { height: height ? parseInt(height) : null, width: width ? parseInt(width) : null } - return CacheManager.handleAuthorCache(res, author, options) + return CacheManager.handleAuthorCache(res, authorId, options) } /** diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index 83efae9075..f03756918a 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -134,22 +134,22 @@ class CacheManager { /** * * @param {import('express').Response} res - * @param {import('../models/Author')} author + * @param {String} authorId * @param {{ format?: string, width?: number, height?: number }} options * @returns */ - async handleAuthorCache(res, author, options = {}) { + async handleAuthorCache(res, authorId, options = {}) { const format = options.format || 'webp' const width = options.width || 400 const height = options.height || null res.type(`image/${format}`) - var path = Path.join(this.ImageCachePath, `${author.id}_${width}${height ? `x${height}` : ''}`) + '.' + format + var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format // Cache exists - if (await fs.pathExists(path)) { - const r = fs.createReadStream(path) + if (await fs.pathExists(cachePath)) { + const r = fs.createReadStream(cachePath) const ps = new stream.PassThrough() stream.pipeline(r, ps, (err) => { if (err) { @@ -160,7 +160,12 @@ class CacheManager { return ps.pipe(res) } - let writtenFile = await resizeImage(author.imagePath, path, width, height) + const author = await Database.authorModel.findByPk(authorId) + if (!author || !author.imagePath || !(await fs.pathExists(author.imagePath))) { + return res.sendStatus(404) + } + + let writtenFile = await resizeImage(author.imagePath, cachePath, width, height) if (!writtenFile) return res.sendStatus(500) var readStream = fs.createReadStream(writtenFile) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index f81bc26df5..c9399d7987 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -216,7 +216,7 @@ class ApiRouter { this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this)) this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this)) this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) - this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) + this.router.get('/authors/:id/image', AuthorController.getImage.bind(this)) this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this)) this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this)) From bf8407274e3ee300af1927ee660d078a7a801e1c Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:45:43 +0200 Subject: [PATCH 337/539] No auth for author images --- server/Auth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index da124b72a9..5b2d8bcda4 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -18,7 +18,7 @@ class Auth { constructor() { // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() - this.ignorePattern = /\/api\/items\/[^/]+\/cover/ + this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/] } /** @@ -28,7 +28,7 @@ class Auth { * @private */ authNotNeeded(req) { - return req.method === 'GET' && this.ignorePattern.test(req.originalUrl) + return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.originalUrl)) } ifAuthNeeded(middleware) { From 68fd1d67cb867c1deeb0cac772dede2935a63527 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:46:09 +0200 Subject: [PATCH 338/539] Remove token from author image URLs --- client/components/covers/AuthorImage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/covers/AuthorImage.vue b/client/components/covers/AuthorImage.vue index 0192636339..e320e552e8 100644 --- a/client/components/covers/AuthorImage.vue +++ b/client/components/covers/AuthorImage.vue @@ -56,7 +56,7 @@ export default { }, imgSrc() { if (!this.imagePath) return null - return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}` + return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}` } }, methods: { From 7ef14aabedae8c4a038943633c6b2c2ec52b067e Mon Sep 17 00:00:00 2001 From: snakehnb Date: Mon, 4 Nov 2024 16:13:14 +0800 Subject: [PATCH 339/539] Avoid parsing first and last names in Chinese, Japanese and Korean languages --- server/utils/parsers/parseNameString.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/utils/parsers/parseNameString.js b/server/utils/parsers/parseNameString.js index 741beb0911..4b16b496f1 100644 --- a/server/utils/parsers/parseNameString.js +++ b/server/utils/parsers/parseNameString.js @@ -52,6 +52,13 @@ module.exports.parse = (nameString) => { } if (splitNames.length) splitNames = splitNames.map((a) => a.trim()) + // If names are in Chinese,Japanese and Korean languages, return as is. + if (/[\u4e00-\u9fff\u3040-\u30ff\u31f0-\u31ff]/.test(splitNames[0])) { + return { + names: splitNames + } + } + var names = [] // 1 name FIRST LAST From 0812e189f74cb0cf176acf7924fb3b91a419a093 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Thu, 7 Nov 2024 03:38:30 +0000 Subject: [PATCH 340/539] Add keyboard input to MultiSelect component --- client/components/ui/MultiSelect.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue index 3701826241..da4bcc13f7 100644 --- a/client/components/ui/MultiSelect.vue +++ b/client/components/ui/MultiSelect.vue @@ -5,9 +5,9 @@
    -
    +
    edit - close + close
    {{ item }}
    @@ -66,7 +66,8 @@ export default { typingTimeout: null, isFocused: false, menu: null, - filteredItems: null + filteredItems: null, + inputFocused: false } }, watch: { @@ -129,6 +130,9 @@ export default { }, 100) this.setInputWidth() }, + setInputFocused(focused) { + this.inputFocused = focused + }, setInputWidth() { setTimeout(() => { var value = this.$refs.input.value From 41fe5373a7096fc050962acb7eaf1522d5fd5d43 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Wed, 6 Nov 2024 22:06:58 -0700 Subject: [PATCH 341/539] Add: check that `migrationsMeta` table is well formed --- server/managers/MigrationManager.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index dc2f9235df..003f8dfa11 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -191,7 +191,21 @@ class MigrationManager { const queryInterface = this.sequelize.getQueryInterface() let migrationsMetaTableExists = await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE) + // If the table exists, check that the `version` and `maxVersion` rows exist + if (migrationsMetaTableExists) { + const [{ count }] = await this.sequelize.query("SELECT COUNT(*) as count FROM :migrationsMeta WHERE key IN ('version', 'maxVersion')", { + replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, + type: Sequelize.QueryTypes.SELECT + }) + if (count < 2) { + Logger.warn(`[MigrationManager] migrationsMeta table exists but is missing 'version' or 'maxVersion' row. Dropping it...`) + await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE) + migrationsMetaTableExists = false + } + } + if (this.isDatabaseNew && migrationsMetaTableExists) { + Logger.warn(`[MigrationManager] migrationsMeta table already exists. Dropping it...`) // This can happen if database was initialized with force: true await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE) migrationsMetaTableExists = false From a5ebd89817cf1e22306252ea613190fcf0315a08 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 7 Nov 2024 16:32:05 -0600 Subject: [PATCH 342/539] Update FolderWatcher to singleton --- server/Server.js | 9 ++++----- server/Watcher.js | 2 +- server/controllers/LibraryController.js | 7 ++++--- server/controllers/MiscController.js | 7 ++++--- server/managers/PodcastManager.js | 9 ++++----- server/routers/ApiRouter.js | 2 -- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/server/Server.js b/server/Server.js index 58a2079ef5..be12946468 100644 --- a/server/Server.js +++ b/server/Server.js @@ -62,7 +62,6 @@ class Server { fs.mkdirSync(global.MetadataPath) } - this.watcher = new Watcher() this.auth = new Auth() // Managers @@ -70,7 +69,7 @@ class Server { this.backupManager = new BackupManager() this.abMergeManager = new AbMergeManager() this.playbackSessionManager = new PlaybackSessionManager() - this.podcastManager = new PodcastManager(this.watcher) + this.podcastManager = new PodcastManager() this.audioMetadataManager = new AudioMetadataMangaer() this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) @@ -147,9 +146,9 @@ class Server { if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) - this.watcher.disabled = true + Watcher.disabled = true } else { - this.watcher.initWatcher(libraries) + Watcher.initWatcher(libraries) } } @@ -435,7 +434,7 @@ class Server { */ async stop() { Logger.info('=== Stopping Server ===') - await this.watcher.close() + Watcher.close() Logger.info('Watcher Closed') return new Promise((resolve) => { diff --git a/server/Watcher.js b/server/Watcher.js index 0e34fc66bf..8c2d652e32 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -409,4 +409,4 @@ class FolderWatcher extends EventEmitter { }, 5000) } } -module.exports = FolderWatcher +module.exports = new FolderWatcher() diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 61ffb5bd2d..0bd499f1cf 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -17,6 +17,7 @@ const naturalSort = createNewSortInstance({ const LibraryScanner = require('../scanner/LibraryScanner') const Scanner = require('../scanner/Scanner') const Database = require('../Database') +const Watcher = require('../Watcher') const libraryFilters = require('../utils/queries/libraryFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const authorFilters = require('../utils/queries/authorFilters') @@ -158,7 +159,7 @@ class LibraryController { SocketAuthority.emitter('library_added', library.toOldJSON(), userFilter) // Add library watcher - this.watcher.addLibrary(library) + Watcher.addLibrary(library) res.json(library.toOldJSON()) } @@ -440,7 +441,7 @@ class LibraryController { req.library.libraryFolders = await req.library.getLibraryFolders() // Update watcher - this.watcher.updateLibrary(req.library) + Watcher.updateLibrary(req.library) hasUpdates = true } @@ -466,7 +467,7 @@ class LibraryController { */ async delete(req, res) { // Remove library watcher - this.watcher.removeLibrary(req.library) + Watcher.removeLibrary(req.library) // Remove collections for library const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(req.library.id) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index f3dd0c6d40..cf901bea03 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -5,6 +5,7 @@ const fs = require('../libs/fsExtra') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const Watcher = require('../Watcher') const libraryItemFilters = require('../utils/queries/libraryItemFilters') const patternValidation = require('../libs/nodeCron/pattern-validation') @@ -557,10 +558,10 @@ class MiscController { switch (type) { case 'add': - this.watcher.onFileAdded(libraryId, path) + Watcher.onFileAdded(libraryId, path) break case 'unlink': - this.watcher.onFileRemoved(libraryId, path) + Watcher.onFileRemoved(libraryId, path) break case 'rename': const oldPath = req.body.oldPath @@ -568,7 +569,7 @@ class MiscController { Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`) return res.sendStatus(400) } - this.watcher.onFileRename(libraryId, oldPath, path) + Watcher.onFileRename(libraryId, oldPath, path) break default: Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 503f47c0ce..f9eb72e4f0 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -1,6 +1,7 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const Watcher = require('../Watcher') const fs = require('../libs/fsExtra') @@ -23,9 +24,7 @@ const AudioFile = require('../objects/files/AudioFile') const LibraryItem = require('../objects/LibraryItem') class PodcastManager { - constructor(watcher) { - this.watcher = watcher - + constructor() { this.downloadQueue = [] this.currentDownload = null @@ -97,7 +96,7 @@ class PodcastManager { } // Ignores all added files to this dir - this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.addIgnoreDir(this.currentDownload.libraryItem.path) // Make sure podcast library item folder exists if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) { @@ -151,7 +150,7 @@ class PodcastManager { SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) - this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) this.currentDownload = null if (this.downloadQueue.length) { this.startPodcastEpisodeDownload(this.downloadQueue.shift()) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c9399d7987..7f21c3ac51 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -45,8 +45,6 @@ class ApiRouter { this.abMergeManager = Server.abMergeManager /** @type {import('../managers/BackupManager')} */ this.backupManager = Server.backupManager - /** @type {import('../Watcher')} */ - this.watcher = Server.watcher /** @type {import('../managers/PodcastManager')} */ this.podcastManager = Server.podcastManager /** @type {import('../managers/AudioMetadataManager')} */ From 850ed4895577e5ed073556e4dd93f08f1a028bf7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 7 Nov 2024 17:26:51 -0600 Subject: [PATCH 343/539] Fix:Podcast episodes duplicated when a scan runs while the episode is downloading #2785 --- server/Server.js | 3 +++ server/Watcher.js | 22 ++++++++++++++++++++-- server/managers/PodcastManager.js | 3 +++ server/objects/PodcastEpisodeDownload.js | 9 +++++---- server/scanner/LibraryItemScanner.js | 9 +++++++++ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/server/Server.js b/server/Server.js index be12946468..e40e7c5748 100644 --- a/server/Server.js +++ b/server/Server.js @@ -149,6 +149,9 @@ class Server { Watcher.disabled = true } else { Watcher.initWatcher(libraries) + Watcher.on('scanFilesChanged', (pendingFileUpdates, pendingTask) => { + LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask) + }) } } diff --git a/server/Watcher.js b/server/Watcher.js index 8c2d652e32..85c13e2a49 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -2,7 +2,6 @@ const Path = require('path') const EventEmitter = require('events') const Watcher = require('./libs/watcher/watcher') const Logger = require('./Logger') -const LibraryScanner = require('./scanner/LibraryScanner') const Task = require('./objects/Task') const TaskManager = require('./managers/TaskManager') @@ -31,6 +30,8 @@ class FolderWatcher extends EventEmitter { this.filesBeingAdded = new Set() + /** @type {Set} */ + this.ignoreFilePathsDownloading = new Set() /** @type {string[]} */ this.ignoreDirs = [] /** @type {string[]} */ @@ -333,7 +334,7 @@ class FolderWatcher extends EventEmitter { } if (this.pendingFileUpdates.length) { - LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask) + this.emit('scanFilesChanged', this.pendingFileUpdates, this.pendingTask) } else { const taskFinishedString = { text: 'No files to scan', @@ -348,12 +349,29 @@ class FolderWatcher extends EventEmitter { }, this.pendingDelay) } + /** + * + * @param {string} path + * @returns {boolean} + */ checkShouldIgnorePath(path) { return !!this.ignoreDirs.find((dirpath) => { return isSameOrSubPath(dirpath, path) }) } + /** + * When scanning a library item folder these files should be ignored + * Either a podcast episode downloading or a file that is pending by the watcher + * + * @param {string} path + * @returns {boolean} + */ + checkShouldIgnoreFilePath(path) { + if (this.pendingFilePaths.includes(path)) return true + return this.ignoreFilePathsDownloading.has(path) + } + /** * Convert to POSIX and remove trailing slash * @param {string} path diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index f9eb72e4f0..96ffcb6a46 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -97,6 +97,7 @@ class PodcastManager { // Ignores all added files to this dir Watcher.addIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.ignoreFilePathsDownloading.add(this.currentDownload.targetPath) // Make sure podcast library item folder exists if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) { @@ -151,6 +152,8 @@ class PodcastManager { SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) + + Watcher.ignoreFilePathsDownloading.delete(this.currentDownload.targetPath) this.currentDownload = null if (this.downloadQueue.length) { this.startPodcastEpisodeDownload(this.downloadQueue.shift()) diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index 2dfdc52e77..86a8580164 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -1,6 +1,6 @@ const Path = require('path') -const uuidv4 = require("uuid").v4 -const { sanitizeFilename } = require('../utils/fileUtils') +const uuidv4 = require('uuid').v4 +const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils') const globals = require('../utils/globals') class PodcastEpisodeDownload { @@ -60,7 +60,7 @@ class PodcastEpisodeDownload { return sanitizeFilename(filename) } get targetPath() { - return Path.join(this.libraryItem.path, this.targetFilename) + return filePathToPOSIX(Path.join(this.libraryItem.path, this.targetFilename)) } get targetRelPath() { return this.targetFilename @@ -74,7 +74,8 @@ class PodcastEpisodeDownload { this.podcastEpisode = podcastEpisode const url = podcastEpisode.enclosure.url - if (decodeURIComponent(url) !== url) { // Already encoded + if (decodeURIComponent(url) !== url) { + // Already encoded this.url = url } else { this.url = encodeURI(url) diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 38608e479f..5edfc2e2b6 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -4,7 +4,9 @@ const { LogLevel, ScanResult } = require('../utils/constants') const fileUtils = require('../utils/fileUtils') const scanUtils = require('../utils/scandir') const libraryFilters = require('../utils/queries/libraryFilters') +const Logger = require('../Logger') const Database = require('../Database') +const Watcher = require('../Watcher') const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') const BookScanner = require('./BookScanner') @@ -128,6 +130,13 @@ class LibraryItemScanner { const libraryFiles = [] for (let i = 0; i < fileItems.length; i++) { const fileItem = fileItems[i] + + if (Watcher.checkShouldIgnoreFilePath(fileItem.fullpath)) { + // Skip file if it's pending + Logger.info(`[LibraryItemScanner] Skipping watcher pending file "${fileItem.fullpath}" during scan of library item path "${libraryItemPath}"`) + continue + } + const newLibraryFile = new LibraryFile() // fileItem.path is the relative path await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) From d7e810fc2f873e05eedb8ec41919656e21496412 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 8 Nov 2024 08:04:50 -0600 Subject: [PATCH 344/539] Update readme localization chart to for web client only --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 44551acb7a..e2d1f10763 100644 --- a/readme.md +++ b/readme.md @@ -114,7 +114,7 @@ server { proxy_pass http://; proxy_redirect http:// https://; - # Prevent 413 Request Entity Too Large error + # Prevent 413 Request Entity Too Large error # by increasing the maximum allowed size of the client request body # For example, set it to 10 GiB client_max_body_size 10240M; @@ -339,7 +339,7 @@ This application is built using [NodeJs](https://nodejs.org/). ### Localization -Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). Translation status +Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). Translation status ### Dev Container Setup From 435b7fda7e8ddc3ef413a4e583ea09f91627d485 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 09:09:18 -0700 Subject: [PATCH 345/539] Add: check for changes to library items --- server/utils/queries/libraryFilters.js | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 34c3fe544b..f66df56872 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -485,6 +485,60 @@ module.exports = { } } } else { + // To reduce the cold-start load time, first check if any library items, series, + // or authors have had an "updatedAt" timestamp since the last time the filter + // data was loaded. If so, we can skip loading all of the data. + // Because many items could change, just check the count of items. + const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + + const changedBooks = await Database.bookModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + } + }, + where: { + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + const changedSeries = await Database.seriesModel.count({ + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + const changedAuthors = await Database.authorModel.count({ + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + if (changedBooks + changedSeries + changedAuthors === 0) { + // If nothing has changed, update the cache to current time for 30 minute + // cache time and return the cached data + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } + + // Something has changed in one of the tables, so reload all of the filter data for library const books = await Database.bookModel.findAll({ include: { model: Database.libraryItemModel, From e57d4cc54435dfa651a7de30a1a4b0a8fcd8d926 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 09:33:34 -0700 Subject: [PATCH 346/539] Add: filter update check to podcast libraries --- server/utils/queries/libraryFilters.js | 41 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index f66df56872..2be415e252 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -462,7 +462,42 @@ module.exports = { numIssues: 0 } + const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + if (mediaType === 'podcast') { + // To reduce the cold-start load time, first check if any podcasts + // have an "updatedAt" timestamp since the last time the filter + // data was loaded. If so, we can skip loading all of the data. + // Because many items could change, just check the count of items instead + // of actually loading the data twice + const changedPodcasts = await Database.podcastModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + } + }, + where: { + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + if (changedPodcasts === 0) { + // If nothing has changed, update the cache to current time for 30 minute + // cache time and return the cached data + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } + + // Something has changed in the podcasts table, so reload all of the filter data for library const podcasts = await Database.podcastModel.findAll({ include: { model: Database.libraryItemModel, @@ -486,10 +521,10 @@ module.exports = { } } else { // To reduce the cold-start load time, first check if any library items, series, - // or authors have had an "updatedAt" timestamp since the last time the filter + // or authors have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. - // Because many items could change, just check the count of items. - const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + // Because many items could change, just check the count of items instead + // of actually loading the data twice const changedBooks = await Database.bookModel.count({ include: { From e8d8b67c0aa170f5b0fe4fe8c5996a00b49bbdc0 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 10:49:12 -0700 Subject: [PATCH 347/539] Add: check for deleted items --- server/utils/queries/libraryFilters.js | 72 ++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 2be415e252..64ad07ee08 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -459,12 +459,29 @@ module.exports = { languages: new Set(), publishers: new Set(), publishedDecades: new Set(), + bookCount: 0, // How many books returned from database query + authorCount: 0, // How many authors returned from database query + seriesCount: 0, // How many series returned from database query + podcastCount: 0, // How many podcasts returned from database query numIssues: 0 } const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 if (mediaType === 'podcast') { + // Check how many podcasts are in library to determine if we need to load all of the data + // This is done to handle the edge case of podcasts having been deleted and not having + // an updatedAt timestamp to trigger a reload of the filter data + const podcastCountFromDatabase = await Database.podcastModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId + } + } + }) + // To reduce the cold-start load time, first check if any podcasts // have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. @@ -490,11 +507,14 @@ module.exports = { }) if (changedPodcasts === 0) { - // If nothing has changed, update the cache to current time for 30 minute - // cache time and return the cached data - Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) - Database.libraryFilterData[libraryId].loadedAt = Date.now() - return cachedFilterData + // If nothing has changed, check if the number of podcasts in + // library is still the same as prior check before updating cache creation time + + if (podcastCountFromDatabase === Database.libraryFilterData[libraryId].podcastCount) { + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } } // Something has changed in the podcasts table, so reload all of the filter data for library @@ -519,7 +539,32 @@ module.exports = { data.languages.add(podcast.language) } } + + // Set podcast count for later comparison + data.podcastCount = podcastCountFromDatabase } else { + const bookCountFromDatabase = await Database.bookModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId + } + } + }) + + const seriesCountFromDatabase = await Database.seriesModel.count({ + where: { + libraryId: libraryId + } + }) + + const authorCountFromDatabase = await Database.authorModel.count({ + where: { + libraryId: libraryId + } + }) + // To reduce the cold-start load time, first check if any library items, series, // or authors have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. @@ -566,13 +611,20 @@ module.exports = { }) if (changedBooks + changedSeries + changedAuthors === 0) { - // If nothing has changed, update the cache to current time for 30 minute - // cache time and return the cached data - Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) - Database.libraryFilterData[libraryId].loadedAt = Date.now() - return cachedFilterData + // If nothing has changed, check if the number of authors, series, and books + // matches the prior check before updating cache creation time + if (bookCountFromDatabase === Database.libraryFilterData[libraryId].bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId].seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } } + // Store the counts for later comparison + data.bookCount = bookCountFromDatabase + data.seriesCount = seriesCountFromDatabase + data.authorCount = authorCountFromDatabase + // Something has changed in one of the tables, so reload all of the filter data for library const books = await Database.bookModel.findAll({ include: { From 1fa67535f9c88466aab8da662f40c7f136fe5e49 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 11:20:02 -0700 Subject: [PATCH 348/539] Update: only run CodeQL and Integration actions if code changed --- .github/workflows/codeql.yml | 77 +++++++++++++++----------- .github/workflows/integration-test.yml | 7 +++ 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a77ab3e0ab..8d43311b7f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,11 +1,25 @@ -name: "CodeQL" +name: 'CodeQL' on: push: - branches: [ 'master' ] + branches: ['master'] + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json pull_request: # The branches below must be a subset of the branches above - branches: [ 'master' ] + branches: ['master'] + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json schedule: - cron: '16 5 * * 4' @@ -21,45 +35,44 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ['javascript'] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: '/language:${{matrix.language}}' diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 3e499468ee..580c0f500a 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -5,6 +5,13 @@ on: push: branches-ignore: - 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json jobs: build: From 713bdcbc419b6b5db9af68effd3b0984be93aa41 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 9 Nov 2024 13:10:46 -0700 Subject: [PATCH 349/539] Add: migration for mediaId to use UUID instead of UUIDV4 --- server/migrations/v2.16.3-uuid-replacement.js | 50 +++++++++++++++++++ server/models/LibraryItem.js | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 server/migrations/v2.16.3-uuid-replacement.js diff --git a/server/migrations/v2.16.3-uuid-replacement.js b/server/migrations/v2.16.3-uuid-replacement.js new file mode 100644 index 0000000000..66bf21ac98 --- /dev/null +++ b/server/migrations/v2.16.3-uuid-replacement.js @@ -0,0 +1,50 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes the `mediaId` column in the `libraryItems` table to be a UUID and match other tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.16.3 migration] UPGRADE BEGIN: 2.16.3-uuid-replacement') + + // Change mediaId column to using the query interface + logger.info('[2.16.3 migration] Changing mediaId column to UUID') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUID' + }) + + // Completed migration + logger.info('[2.16.3 migration] UPGRADE END: 2.16.3-uuid-replacement') +} + +/** + * This downward migration script changes the `mediaId` column in the `libraryItems` table to be a UUIDV4 again. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.16.3 migration] DOWNGRADE BEGIN: 2.16.3-uuid-replacement') + + // Change mediaId column to using the query interface + logger.info('[2.16.3 migration] Changing mediaId column to UUIDV4') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUIDV4' + }) + + // Completed migration + logger.info('[2.16.3 migration] DOWNGRADE END: 2.16.3-uuid-replacement') +} + +module.exports = { up, down } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 17c3b12586..c2a0178584 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -1059,7 +1059,7 @@ class LibraryItem extends Model { ino: DataTypes.STRING, path: DataTypes.STRING, relPath: DataTypes.STRING, - mediaId: DataTypes.UUIDV4, + mediaId: DataTypes.UUID, mediaType: DataTypes.STRING, isFile: DataTypes.BOOLEAN, isMissing: DataTypes.BOOLEAN, From 161a3f4da925821d1f2ff41e0d9954291588a308 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 9 Nov 2024 13:20:59 -0700 Subject: [PATCH 350/539] Update migrations changelog for 2.16.3 --- server/migrations/changelog.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 3623300f4c..bffd4682d6 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,8 +2,9 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.16.3 | v2.16.3-uuid-replacement | Changes `mediaId` column in `libraryItem` table to match the primary key type of `books` and `podcasts` | From 2e970cbb3984e4f65a214993c66474e2800e5803 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 9 Nov 2024 18:03:50 -0600 Subject: [PATCH 351/539] Fix:Series Progress filters incorrect - showing for any users progress #2923 --- server/utils/queries/seriesFilters.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js index 06ca254793..c293f1dfb0 100644 --- a/server/utils/queries/seriesFilters.js +++ b/server/utils/queries/seriesFilters.js @@ -73,15 +73,19 @@ module.exports = { userPermissionBookWhere.replacements.filterValue = filterValue } else if (filterGroup === 'progress') { if (filterValue === 'not-finished') { - attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'finished') { - const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'not-started') { - const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)' + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)' seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'in-progress') { - attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0' + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0' + userPermissionBookWhere.replacements.userId = user.id } } From a38248217303413fd9bb14bd96c79801b43c60b2 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 10 Nov 2024 08:34:47 +0200 Subject: [PATCH 352/539] Add in-memory user cache --- server/Auth.js | 30 ++++------- server/models/User.js | 112 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 5b2d8bcda4..b0046799b9 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -990,28 +990,18 @@ class Auth { }) } } - - Database.userModel - .update( - { - pash: pw - }, - { - where: { id: matchingUser.id } - } - ) - .then(() => { - Logger.info(`[Auth] User "${matchingUser.username}" changed password`) - res.json({ - success: true - }) + try { + await matchingUser.update({ pash: pw }) + Logger.info(`[Auth] User "${matchingUser.username}" changed password`) + res.json({ + success: true }) - .catch((error) => { - Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) - res.json({ - error: 'Unknown error' - }) + } catch (error) { + Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) + res.json({ + error: 'Unknown error' }) + } } } diff --git a/server/models/User.js b/server/models/User.js index 906a7d68e7..259f841ca0 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -3,6 +3,53 @@ const sequelize = require('sequelize') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const { isNullOrNaN } = require('../utils') +const { LRUCache } = require('lru-cache') + +class UserCache { + constructor() { + this.cache = new LRUCache({ max: 100 }) + } + + getById(id) { + const user = this.cache.get(id) + return user + } + + getByEmail(email) { + const user = this.cache.find((u) => u.email === email) + return user + } + + getByUsername(username) { + const user = this.cache.find((u) => u.username === username) + return user + } + + getByOldId(oldUserId) { + const user = this.cache.find((u) => u.extraData?.oldUserId === oldUserId) + return user + } + + getByOpenIDSub(sub) { + const user = this.cache.find((u) => u.extraData?.authOpenIDSub === sub) + return user + } + + set(user) { + user.fromCache = true + this.cache.set(user.id, user) + } + + delete(userId) { + this.cache.delete(userId) + } + + maybeInvalidate(user) { + if (!user.fromCache) this.delete(user.id) + } +} + +const userCache = new UserCache() const { DataTypes, Model } = sequelize @@ -206,7 +253,11 @@ class User extends Model { */ static async getUserByUsername(username) { if (!username) return null - return this.findOne({ + + const cachedUser = userCache.getByUsername(username) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { username: { [sequelize.Op.like]: username @@ -214,6 +265,10 @@ class User extends Model { }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -223,7 +278,11 @@ class User extends Model { */ static async getUserByEmail(email) { if (!email) return null - return this.findOne({ + + const cachedUser = userCache.getByEmail(email) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { email: { [sequelize.Op.like]: email @@ -231,6 +290,10 @@ class User extends Model { }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -240,9 +303,17 @@ class User extends Model { */ static async getUserById(userId) { if (!userId) return null - return this.findByPk(userId, { + + const cachedUser = userCache.getById(userId) + if (cachedUser) return cachedUser + + const user = await this.findByPk(userId, { include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -254,12 +325,19 @@ class User extends Model { */ static async getUserByIdOrOldId(userId) { if (!userId) return null - return this.findOne({ + const cachedUser = userCache.getById(userId) || userCache.getByOldId(userId) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { [sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }] }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -269,10 +347,18 @@ class User extends Model { */ static async getUserByOpenIDSub(sub) { if (!sub) return null - return this.findOne({ + + const cachedUser = userCache.getByOpenIDSub(sub) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub), include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -623,6 +709,7 @@ class User extends Model { mediaProgress = await this.sequelize.models.mediaProgress.create(newMediaProgressPayload) this.mediaProgresses.push(mediaProgress) } + userCache.maybeInvalidate(this) return { mediaProgress } @@ -804,6 +891,21 @@ class User extends Model { return hasUpdates } + + async update(values, options) { + userCache.maybeInvalidate(this) + return await super.update(values, options) + } + + async save(options) { + userCache.maybeInvalidate(this) + return await super.save(options) + } + + async destroy(options) { + userCache.delete(this.id) + await super.destroy(options) + } } module.exports = User From 0d54b571517343427ef2a314a831330a196adf00 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 11 Nov 2024 21:20:53 -0700 Subject: [PATCH 353/539] Add: PR template --- .github/pull_request_template.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..f41e46cce7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ + + +## Brief summary + + + +## In-depth Description + + + +## How have you tested this? + + + +## Screenshots + + From b50d7f09278c74eeccffdff1ecc0f2eb4cb8f04f Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 12 Nov 2024 07:25:10 +0200 Subject: [PATCH 354/539] Remove unnecessary socket event causing OOM --- client/pages/library/_library/podcast/download-queue.vue | 5 ----- server/managers/PodcastManager.js | 2 -- 2 files changed, 7 deletions(-) diff --git a/client/pages/library/_library/podcast/download-queue.vue b/client/pages/library/_library/podcast/download-queue.vue index 777ddfc16d..5f4bab629c 100644 --- a/client/pages/library/_library/podcast/download-queue.vue +++ b/client/pages/library/_library/podcast/download-queue.vue @@ -104,9 +104,6 @@ export default { this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id) } }, - episodeDownloadQueueUpdated(downloadQueueDetails) { - this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId) - }, async loadInitialDownloadQueue() { this.processing = true const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => { @@ -128,7 +125,6 @@ export default { this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.on('episode_download_started', this.episodeDownloadStarted) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) - this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated) } }, mounted() { @@ -138,7 +134,6 @@ export default { this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.off('episode_download_started', this.episodeDownloadStarted) this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished) - this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated) } } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 96ffcb6a46..01e661d9ed 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -63,7 +63,6 @@ class PodcastManager { } async startPodcastEpisodeDownload(podcastEpisodeDownload) { - SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) if (this.currentDownload) { this.downloadQueue.push(podcastEpisodeDownload) SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient()) @@ -149,7 +148,6 @@ class PodcastManager { TaskManager.taskFinished(task) SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) - SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) From 8626fa3e00871555a80a647e058cd8f62ba2ea59 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 12 Nov 2024 07:37:38 +0200 Subject: [PATCH 355/539] Add episode_download_queue_cleared socket event --- client/pages/item/_id/index.vue | 7 +++++++ server/managers/PodcastManager.js | 1 + 2 files changed, 8 insertions(+) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 57a1ae7435..1baf521c70 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -638,6 +638,11 @@ export default { this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id) } }, + episodeDownloadQueueCleared(libraryItemId) { + if (libraryItemId === this.libraryItemId) { + this.episodeDownloadsQueued = [] + } + }, rssFeedOpen(data) { if (data.entityId === this.libraryItemId) { console.log('RSS Feed Opened', data) @@ -776,6 +781,7 @@ export default { this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.on('episode_download_started', this.episodeDownloadStarted) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) + this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared) }, beforeDestroy() { this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) @@ -787,6 +793,7 @@ export default { this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.off('episode_download_started', this.episodeDownloadStarted) this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished) + this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared) } } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 01e661d9ed..0a32e3cad1 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -46,6 +46,7 @@ class PodcastManager { var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId) Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`) this.downloadQueue = this.downloadQueue.filter((d) => d.libraryItemId !== libraryItemId) + SocketAuthority.emitter('episode_download_queue_cleared', libraryItemId) } } From c1b626da147c55c1723f236a7a6eed4d9b4deae7 Mon Sep 17 00:00:00 2001 From: Charlie Date: Wed, 30 Oct 2024 15:42:23 +0000 Subject: [PATCH 356/539] Translated using Weblate (French) Currently translated at 97.5% (1045 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index d31c59714c..a32296f768 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -180,6 +180,7 @@ "HeaderRemoveEpisodes": "Suppression de {0} épisodes", "HeaderSavedMediaProgress": "Progression de la sauvegarde des médias", "HeaderSchedule": "Programmation", + "HeaderScheduleEpisodeDownloads": "Programmer des téléchargements automatiques d'épisodes", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderSession": "Session", "HeaderSetBackupSchedule": "Activer la sauvegarde automatique", @@ -225,6 +226,7 @@ "LabelAllUsersExcludingGuests": "Tous les utilisateurs à l’exception des invités", "LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", + "LabelApiToken": "Token API", "LabelAppend": "Ajouter", "LabelAudioBitrate": "Débit audio (par exemple 128k)", "LabelAudioChannels": "Canaux audio (1 ou 2)", @@ -261,6 +263,7 @@ "LabelChapters": "Chapitres", "LabelChaptersFound": "chapitres trouvés", "LabelClickForMoreInfo": "Cliquez ici pour plus d’informations", + "LabelClickToUseCurrentValue": "Cliquez pour utiliser la valeur actuelle", "LabelClosePlayer": "Fermer le lecteur", "LabelCodec": "Codec", "LabelCollapseSeries": "Réduire les séries", @@ -306,7 +309,7 @@ "LabelEmailSettingsRejectUnauthorized": "Rejeter les certificats non autorisés", "LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « Attaque de l’homme du milieu ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.", "LabelEmailSettingsSecure": "Sécurisé", - "LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Adresse de test", "LabelEmbeddedCover": "Couverture du livre intégrée", "LabelEnable": "Activer", @@ -322,9 +325,12 @@ "LabelEnd": "Fin", "LabelEndOfChapter": "Fin du chapitre", "LabelEpisode": "Épisode", + "LabelEpisodeNotLinkedToRssFeed": "Épisode non lié au flux RSS", + "LabelEpisodeNumber": "Épisode n°{0}", "LabelEpisodeTitle": "Titre de l’épisode", "LabelEpisodeType": "Type de l’épisode", "LabelEpisodes": "Épisodes", + "LabelEpisodic": "Épisodique", "LabelExample": "Exemple", "LabelExpandSeries": "Développer la série", "LabelExpandSubSeries": "Développer les sous-séries", @@ -352,6 +358,7 @@ "LabelFontScale": "Taille de la police de caractère", "LabelFontStrikethrough": "Barrer", "LabelFormat": "Format", + "LabelFull": "Complet", "LabelGenre": "Genre", "LabelGenres": "Genres", "LabelHardDeleteFile": "Suppression du fichier", @@ -407,6 +414,9 @@ "LabelLowestPriority": "Priorité la plus basse", "LabelMatchExistingUsersBy": "Correspondance avec les utilisateurs existants", "LabelMatchExistingUsersByDescription": "Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO", + "LabelMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger. 0 pour illimité.", + "LabelMaxEpisodesToDownloadPerCheck": "Nombre maximum de nouveaux épisodes à télécharger par vérification", + "LabelMaxEpisodesToKeep": "Nombre maximum d’épisodes à conserver", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", "LabelMetaTag": "Balise de métadonnée", @@ -452,12 +462,14 @@ "LabelOpenIDGroupClaimDescription": "Nom de la demande OpenID qui contient une liste des groupes de l’utilisateur. Communément appelé groups. Si elle est configurée, l’application attribuera automatiquement des rôles en fonction de l’appartenance de l’utilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, l’application attribuera le rôle correspondant au niveau d’accès le plus élevé. Si aucun groupe ne correspond, l’accès sera refusé.", "LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOverwrite": "Écraser", + "LabelPaginationPageXOfY": "Page {0} sur {1}", "LabelPassword": "Mot de passe", "LabelPath": "Chemin", "LabelPermanent": "Permanent", "LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque", "LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes", "LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint", + "LabelPermissionsCreateEreader": "Peut créer une liseuse", "LabelPermissionsDelete": "Peut supprimer", "LabelPermissionsDownload": "Peut télécharger", "LabelPermissionsUpdate": "Peut mettre à jour", @@ -502,18 +514,24 @@ "LabelRedo": "Refaire", "LabelRegion": "Région", "LabelReleaseDate": "Date de parution", + "LabelRemoveAllMetadataAbs": "Supprimer tous les fichiers metadata.abs", + "LabelRemoveAllMetadataJson": "Supprimer tous les fichiers metadata.json", "LabelRemoveCover": "Supprimer la couverture", + "LabelRemoveMetadataFile": "Supprimer les fichiers de métadonnées dans les dossiers des éléments de la bibliothèque", + "LabelRemoveMetadataFileHelp": "Supprimer tous les fichiers metadata.json et metadata.abs de vos dossiers {0}.", "LabelRowsPerPage": "Lignes par page", "LabelSearchTerm": "Terme de recherche", "LabelSearchTitle": "Titre de recherche", "LabelSearchTitleOrASIN": "Recherche du titre ou ASIN", "LabelSeason": "Saison", + "LabelSeasonNumber": "Saison n°{0}", "LabelSelectAll": "Tout sélectionner", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours", "LabelSelectUsers": "Sélectionner les utilisateurs", "LabelSendEbookToDevice": "Envoyer le livre numérique à…", "LabelSequence": "Séquence", + "LabelSerial": "N° de série", "LabelSeries": "Séries", "LabelSeriesName": "Nom de la série", "LabelSeriesProgress": "Progression de séries", @@ -542,6 +560,7 @@ "LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent qu’un seul livre seront masquées sur la page de la série et sur les étagères de la page d’accueil.", "LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page d’accueil", "LabelSettingsLibraryBookshelfView": "Utiliser la vue étagère pour la bibliothèque", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Le pourcentage d'achèvement est supérieur à", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "L’étagère de la page d’accueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre n’est en cours. L’activation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.", "LabelSettingsParseSubtitles": "Analyser les sous-titres", @@ -626,6 +645,7 @@ "LabelTracksMultiTrack": "Piste multiple", "LabelTracksNone": "Aucune piste", "LabelTracksSingleTrack": "Piste simple", + "LabelTrailer": "Bande-annonce", "LabelType": "Type", "LabelUnabridged": "Version intégrale", "LabelUndo": "Annuler", From 3a5f6ab6f14f18b3194934cefd96e5195dabc4a5 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 30 Oct 2024 20:17:01 +0000 Subject: [PATCH 357/539] Translated using Weblate (Russian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index d27f138f8b..b0743af630 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -229,7 +229,7 @@ "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelApiToken": "Токен API", "LabelAppend": "Добавить", - "LabelAudioBitrate": "Битрейт аудио (напр. 128k)", + "LabelAudioBitrate": "Битрейт (напр. 128k)", "LabelAudioChannels": "Аудиоканалы (1 или 2)", "LabelAudioCodec": "Аудиокодек", "LabelAuthor": "Автор", @@ -366,7 +366,7 @@ "LabelHardDeleteFile": "Жесткое удаление файла", "LabelHasEbook": "Есть e-книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга", - "LabelHideSubtitles": "Скрыть субтитры", + "LabelHideSubtitles": "Скрыть серии", "LabelHighestPriority": "Наивысший приоритет", "LabelHost": "Хост", "LabelHour": "Часы", @@ -496,8 +496,8 @@ "LabelPubDate": "Дата публикации", "LabelPublishYear": "Год публикации", "LabelPublishedDate": "Опубликовано {0}", - "LabelPublishedDecade": "Опубликованное десятилетие", - "LabelPublishedDecades": "Опубликованные десятилетия", + "LabelPublishedDecade": "Декада публикации", + "LabelPublishedDecades": "Декады публикации", "LabelPublisher": "Издатель", "LabelPublishers": "Издатели", "LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца", @@ -588,7 +588,7 @@ "LabelShareURL": "Общедоступный URL", "LabelShowAll": "Показать все", "LabelShowSeconds": "Отображать секунды", - "LabelShowSubtitles": "Показать субтитры", + "LabelShowSubtitles": "Показать серии", "LabelSize": "Размер", "LabelSleepTimer": "Таймер сна", "LabelSlug": "Слизень", From f161158d838e00cebd1b458a4097048c90b2fad8 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 1 Nov 2024 15:55:01 +0000 Subject: [PATCH 358/539] Translated using Weblate (Spanish) Currently translated at 98.3% (1053 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index dbd8bbc6ec..fea1a85f34 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -415,6 +415,9 @@ "LabelMatchExistingUsersBy": "Emparejar a los usuarios existentes por", "LabelMatchExistingUsersByDescription": "Se utiliza para conectar usuarios existentes. Una vez conectados, los usuarios serán emparejados por un identificador único de su proveedor de SSO", "LabelMaxEpisodesToDownload": "Número máximo # de episodios para descargar. Usa 0 para descargar una cantidad ilimitada.", + "LabelMaxEpisodesToDownloadPerCheck": "Número máximo de episodios nuevos que se descargarán por comprobación", + "LabelMaxEpisodesToKeep": "Número máximo de episodios que se mantendrán", + "LabelMaxEpisodesToKeepHelp": "El valor 0 no establece un límite máximo. Después de que se descargue automáticamente un nuevo episodio, esto eliminará el episodio más antiguo si tiene más de X episodios. Esto solo eliminará 1 episodio por nueva descarga.", "LabelMediaPlayer": "Reproductor de Medios", "LabelMediaType": "Tipo de multimedia", "LabelMetaTag": "Metaetiqueta", @@ -460,12 +463,14 @@ "LabelOpenIDGroupClaimDescription": "Nombre de la declaración OpenID que contiene una lista de grupos del usuario. Comúnmente conocidos como grupos. Si se configura, la aplicación asignará automáticamente roles en función de la pertenencia a grupos del usuario, siempre que estos grupos se denominen \"admin\", \"user\" o \"guest\" en la notificación. La solicitud debe contener una lista, y si un usuario pertenece a varios grupos, la aplicación asignará el rol correspondiente al mayor nivel de acceso. Si ningún grupo coincide, se denegará el acceso.", "LabelOpenRSSFeed": "Abrir Fuente RSS", "LabelOverwrite": "Sobrescribir", + "LabelPaginationPageXOfY": "Página {0} de {1}", "LabelPassword": "Contraseña", "LabelPath": "Ruta de carpeta", "LabelPermanent": "Permanente", "LabelPermissionsAccessAllLibraries": "Puede Accesar a Todas las bibliotecas", "LabelPermissionsAccessAllTags": "Pueda Accesar a Todas las Etiquetas", "LabelPermissionsAccessExplicitContent": "Puede Accesar a Contenido Explicito", + "LabelPermissionsCreateEreader": "Puede crear un gestor de proyectos", "LabelPermissionsDelete": "Puede Eliminar", "LabelPermissionsDownload": "Puede Descargar", "LabelPermissionsUpdate": "Puede Actualizar", @@ -510,18 +515,24 @@ "LabelRedo": "Rehacer", "LabelRegion": "Región", "LabelReleaseDate": "Fecha de Estreno", + "LabelRemoveAllMetadataAbs": "Eliminar todos los archivos metadata.abs", + "LabelRemoveAllMetadataJson": "Eliminar todos los archivos metadata.json", "LabelRemoveCover": "Remover Portada", + "LabelRemoveMetadataFile": "Eliminar archivos de metadatos en carpetas de elementos de biblioteca", + "LabelRemoveMetadataFileHelp": "Elimine todos los archivos metadata.json y metadata.abs de sus carpetas {0}.", "LabelRowsPerPage": "Filas por página", "LabelSearchTerm": "Buscar Termino", "LabelSearchTitle": "Buscar Titulo", "LabelSearchTitleOrASIN": "Buscar Título o ASIN", "LabelSeason": "Temporada", + "LabelSeasonNumber": "Sesión #{0}", "LabelSelectAll": "Seleccionar todo", "LabelSelectAllEpisodes": "Seleccionar todos los episodios", "LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles", "LabelSelectUsers": "Seleccionar usuarios", "LabelSendEbookToDevice": "Enviar Ebook a...", "LabelSequence": "Secuencia", + "LabelSerial": "Serial", "LabelSeries": "Series", "LabelSeriesName": "Nombre de la Serie", "LabelSeriesProgress": "Progreso de la Serie", @@ -550,6 +561,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Las series con un solo libro no aparecerán en la página de series ni la repisa para series de la página principal.", "LabelSettingsHomePageBookshelfView": "Usar la vista de librero en la página principal", "LabelSettingsLibraryBookshelfView": "Usar la vista de librero en la biblioteca", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "El porcentaje completado es mayor que", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "El tiempo restante es menor a (segundos)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Marcar el archivo multimedia como terminado cuando", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Saltar libros anteriores de la serie Continuada", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "El estante de la página de inicio de Continuar Serie muestra el primer libro no iniciado de una serie que tenga por lo menos un libro finalizado y no tenga libros en progreso. Habilitar esta opción le permitirá continuar series desde el último libro que ha completado en vez del primer libro que no ha empezado.", "LabelSettingsParseSubtitles": "Extraer Subtítulos", @@ -614,6 +628,7 @@ "LabelTimeDurationXMinutes": "{0} minutos", "LabelTimeDurationXSeconds": "{0} segundos", "LabelTimeInMinutes": "Tiempo en minutos", + "LabelTimeLeft": "Quedan {0}", "LabelTimeListened": "Tiempo Escuchando", "LabelTimeListenedToday": "Tiempo Escuchando Hoy", "LabelTimeRemaining": "{0} restante", @@ -634,6 +649,7 @@ "LabelTracksMultiTrack": "Varias pistas", "LabelTracksNone": "Ninguna pista", "LabelTracksSingleTrack": "Una pista", + "LabelTrailer": "Tráiler", "LabelType": "Tipo", "LabelUnabridged": "No Abreviado", "LabelUndo": "Deshacer", @@ -650,6 +666,7 @@ "LabelUseAdvancedOptions": "Usar opciones avanzadas", "LabelUseChapterTrack": "Usar pista por capitulo", "LabelUseFullTrack": "Usar pista completa", + "LabelUseZeroForUnlimited": "Utilice 0 para ilimitado", "LabelUser": "Usuario", "LabelUsername": "Nombre de Usuario", "LabelValue": "Valor", @@ -708,6 +725,7 @@ "MessageConfirmPurgeCache": "Purgar el caché eliminará el directorio completo ubicado en /metadata/cache.

    ¿Está seguro que desea eliminar el directorio del caché?", "MessageConfirmPurgeItemsCache": "Purgar la caché de los elementos eliminará todo el directorio /metadata/cache/items.
    ¿Estás seguro?", "MessageConfirmQuickEmbed": "¡Advertencia! La integración rápida no realiza copias de seguridad a ninguno de tus archivos de audio. Asegúrate de haber realizado una copia de los mismos previamente.

    ¿Deseas continuar?", + "MessageConfirmQuickMatchEpisodes": "El reconocimiento rápido de extensiones sobrescribirá los detalles si se encuentra una coincidencia. Se actualizarán las extensiones no reconocidas. ¿Está seguro?", "MessageConfirmReScanLibraryItems": "¿Estás seguro de querer re escanear {0} elemento(s)?", "MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?", "MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?", @@ -715,6 +733,7 @@ "MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?", "MessageConfirmRemoveListeningSessions": "¿Está seguro que desea remover {0} sesiones de escuchar?", + "MessageConfirmRemoveMetadataFiles": "¿Está seguro de que desea eliminar todos los archivos de metadatos.{0} en las carpetas de elementos de su biblioteca?", "MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?", "MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{0}\"?", "MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?", @@ -893,6 +912,7 @@ "StatsYearInReview": "RESEÑA DEL AÑO", "ToastAccountUpdateSuccess": "Cuenta actualizada", "ToastAppriseUrlRequired": "Debes ingresar una URL de Apprise", + "ToastAsinRequired": "Se requiere ASIN", "ToastAuthorImageRemoveSuccess": "Se eliminó la imagen del autor", "ToastAuthorNotFound": "No se encontró el autor \"{0}\"", "ToastAuthorRemoveSuccess": "Autor eliminado", From 4be2909b245b0598e3490a9ef1085ffab8b7d81a Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 1 Nov 2024 14:39:45 +0000 Subject: [PATCH 359/539] Translated using Weblate (French) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index a32296f768..a1f5c2c86a 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -122,7 +122,7 @@ "HeaderBackups": "Sauvegardes", "HeaderChangePassword": "Modifier le mot de passe", "HeaderChapters": "Chapitres", - "HeaderChooseAFolder": "Choisir un dossier", + "HeaderChooseAFolder": "Sélectionner un dossier", "HeaderCollection": "Collection", "HeaderCollectionItems": "Entrées de la collection", "HeaderCover": "Couverture", @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Mise à jour de la notification", "HeaderNotifications": "Notifications", "HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect", + "HeaderOpenListeningSessions": "Ouvrir les sessions d'écoutes", "HeaderOpenRSSFeed": "Ouvrir le flux RSS", "HeaderOtherFiles": "Autres fichiers", "HeaderPasswordAuthentication": "Authentification par mot de passe", @@ -329,6 +330,7 @@ "LabelEpisodeNumber": "Épisode n°{0}", "LabelEpisodeTitle": "Titre de l’épisode", "LabelEpisodeType": "Type de l’épisode", + "LabelEpisodeUrlFromRssFeed": "URL de l’épisode à partir du flux RSS", "LabelEpisodes": "Épisodes", "LabelEpisodic": "Épisodique", "LabelExample": "Exemple", @@ -417,6 +419,7 @@ "LabelMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger. 0 pour illimité.", "LabelMaxEpisodesToDownloadPerCheck": "Nombre maximum de nouveaux épisodes à télécharger par vérification", "LabelMaxEpisodesToKeep": "Nombre maximum d’épisodes à conserver", + "LabelMaxEpisodesToKeepHelp": "La valeur 0 ne définit aucune limite maximale. Une fois qu’un nouvel épisode est téléchargé automatiquement, l’épisode le plus ancien sera supprimé si vous avez plus de X épisodes. Cela ne supprimera qu’un seul épisode par nouveau téléchargement.", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", "LabelMetaTag": "Balise de métadonnée", @@ -561,6 +564,8 @@ "LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page d’accueil", "LabelSettingsLibraryBookshelfView": "Utiliser la vue étagère pour la bibliothèque", "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Le pourcentage d'achèvement est supérieur à", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Le temps restant est inférieur à (secondes)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Marquer l’élément multimédia comme terminé lorsque", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "L’étagère de la page d’accueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre n’est en cours. L’activation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.", "LabelSettingsParseSubtitles": "Analyser les sous-titres", @@ -625,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} minutes", "LabelTimeDurationXSeconds": "{0} secondes", "LabelTimeInMinutes": "Temps en minutes", + "LabelTimeLeft": "{0} restant", "LabelTimeListened": "Temps d’écoute", "LabelTimeListenedToday": "Nombres d’écoutes aujourd’hui", "LabelTimeRemaining": "{0} restantes", @@ -662,6 +668,7 @@ "LabelUseAdvancedOptions": "Utiliser les options avancées", "LabelUseChapterTrack": "Utiliser la piste du chapitre", "LabelUseFullTrack": "Utiliser la piste complète", + "LabelUseZeroForUnlimited": "0 pour illimité", "LabelUser": "Utilisateur", "LabelUsername": "Nom d’utilisateur", "LabelValue": "Valeur", @@ -708,7 +715,7 @@ "MessageConfirmDeleteMetadataProvider": "Êtes-vous sûr·e de vouloir supprimer le fournisseur de métadonnées personnalisées « {0} » ?", "MessageConfirmDeleteNotification": "Êtes-vous sûr·e de vouloir supprimer cette notification ?", "MessageConfirmDeleteSession": "Êtes-vous sûr·e de vouloir supprimer cette session ?", - "MessageConfirmEmbedMetadataInAudioFiles": "Souhaitez-vous vraiment intégrer des métadonnées dans {0} fichiers audio ?", + "MessageConfirmEmbedMetadataInAudioFiles": "Êtes-vous sûr·e de vouloir intégrer des métadonnées dans {0} fichiers audio ?", "MessageConfirmForceReScan": "Êtes-vous sûr·e de vouloir lancer une analyse forcée ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr·e de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr·e de vouloir marquer tous les épisodes comme non terminés ?", @@ -719,7 +726,8 @@ "MessageConfirmNotificationTestTrigger": "Déclencher cette notification avec des données de test ?", "MessageConfirmPurgeCache": "La purge du cache supprimera l’intégralité du répertoire à /metadata/cache.

    Êtes-vous sûr·e de vouloir supprimer le répertoire de cache ?", "MessageConfirmPurgeItemsCache": "Purger le cache des éléments supprimera l'ensemble du répertoire /metadata/cache/items.
    Êtes-vous sûr ?", - "MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

    Souhaitez-vous continuer ?", + "MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

    Êtes-vous sûr·e de vouloir continuer ?", + "MessageConfirmQuickMatchEpisodes": "Les épisodes correspondants seront écrasés si une correspondance est trouvée. Seuls les épisodes non correspondants seront mis à jour. Êtes-vous sûr·e ?", "MessageConfirmReScanLibraryItems": "Êtes-vous sûr·e de vouloir réanalyser {0} éléments ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr·e de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer l’auteur « {0} » ?", @@ -727,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer l’épisode « {0} » ?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes ?", "MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions d’écoute ?", + "MessageConfirmRemoveMetadataFiles": "Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers d’éléments de votre bibliothèque ?", "MessageConfirmRemoveNarrator": "Êtes-vous sûr·e de vouloir supprimer le narrateur « {0} » ?", "MessageConfirmRemovePlaylist": "Êtes-vous sûr·e de vouloir supprimer la liste de lecture « {0} » ?", "MessageConfirmRenameGenre": "Êtes-vous sûr·e de vouloir renommer le genre « {0} » en « {1} » pour tous les éléments ?", @@ -807,6 +816,7 @@ "MessagePodcastSearchField": "Saisissez le terme de recherche ou l'URL du flux RSS", "MessageQuickEmbedInProgress": "Intégration rapide en cours", "MessageQuickEmbedQueue": "En file d'attente pour une intégration rapide ({0} dans la file d'attente)", + "MessageQuickMatchAllEpisodes": "Associer rapidement tous les épisodes", "MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». N’écrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.", "MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)", @@ -905,6 +915,7 @@ "StatsYearInReview": "BILAN DE L’ANNÉE", "ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAppriseUrlRequired": "Vous devez entrer une URL Apprise", + "ToastAsinRequired": "ASIN requis", "ToastAuthorImageRemoveSuccess": "Image de l’auteur supprimée", "ToastAuthorNotFound": "Auteur \"{0}\" non trouvé", "ToastAuthorRemoveSuccess": "Auteur supprimé", @@ -924,6 +935,8 @@ "ToastBackupUploadSuccess": "Sauvegarde téléversée", "ToastBatchDeleteFailed": "Échec de la suppression par lot", "ToastBatchDeleteSuccess": "Suppression par lot réussie", + "ToastBatchQuickMatchFailed": "Échec de la correspondance rapide par lot !", + "ToastBatchQuickMatchStarted": "La correspondance rapide par lots de {0} livres a commencé !", "ToastBatchUpdateFailed": "Échec de la mise à jour par lot", "ToastBatchUpdateSuccess": "Mise à jour par lot terminée", "ToastBookmarkCreateFailed": "Échec de la création de signet", @@ -935,6 +948,7 @@ "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", "ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre", "ToastChaptersRemoved": "Chapitres supprimés", + "ToastChaptersUpdated": "Chapitres mis à jour", "ToastCollectionItemsAddFailed": "Échec de l’ajout de(s) élément(s) à la collection", "ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi", "ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection", @@ -952,11 +966,14 @@ "ToastEncodeCancelSucces": "Encodage annulé", "ToastEpisodeDownloadQueueClearFailed": "Échec de la suppression de la file d'attente", "ToastEpisodeDownloadQueueClearSuccess": "File d’attente de téléchargement des épisodes effacée", + "ToastEpisodeUpdateSuccess": "{0} épisodes mis à jour", "ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil", "ToastFailedToLoadData": "Échec du chargement des données", + "ToastFailedToMatch": "Échec de la correspondance", "ToastFailedToShare": "Échec du partage", "ToastFailedToUpdate": "Échec de la mise à jour", "ToastInvalidImageUrl": "URL de l'image invalide", + "ToastInvalidMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger non valide", "ToastInvalidUrl": "URL invalide", "ToastItemCoverUpdateSuccess": "Couverture mise à jour", "ToastItemDeletedFailed": "La suppression de l'élément à échouée", @@ -975,14 +992,21 @@ "ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée", "ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour", "ToastMatchAllAuthorsFailed": "Tous les auteurs et autrices n’ont pas pu être classés", + "ToastMetadataFilesRemovedError": "Erreur lors de la suppression des fichiers « metadata.{0} »", + "ToastMetadataFilesRemovedNoneFound": "Aucun fichier « metadata.{0} » trouvé dans la bibliothèque", + "ToastMetadataFilesRemovedNoneRemoved": "Aucun fichier « metadata.{0} » n’a été supprimé", + "ToastMetadataFilesRemovedSuccess": "{0} fichiers metadata.{1} supprimés", + "ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin", "ToastNameEmailRequired": "Le nom et le courriel sont requis", "ToastNameRequired": "Le nom est requis", + "ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés", "ToastNewUserCreatedFailed": "La création du compte à échouée : « {0} »", "ToastNewUserCreatedSuccess": "Nouveau compte créé", "ToastNewUserLibraryError": "Au moins une bibliothèque est requise", "ToastNewUserPasswordError": "Un mot de passe est requis, seul l’utilisateur root peut avoir un mot de passe vide", "ToastNewUserTagError": "Au moins une étiquette est requise", "ToastNewUserUsernameError": "Entrez un nom d’utilisateur", + "ToastNoNewEpisodesFound": "Aucun nouvel épisode trouvé", "ToastNoUpdatesNecessary": "Aucune mise à jour nécessaire", "ToastNotificationCreateFailed": "La création de la notification à échouée", "ToastNotificationDeleteFailed": "La suppression de la notification à échouée", @@ -1001,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "Échec de la récupération du flux du podcast", "ToastPodcastNoEpisodesInFeed": "Aucun épisode trouvé dans le flux RSS", "ToastPodcastNoRssFeed": "Le podcast n’a pas de flux RSS", + "ToastProgressIsNotBeingSynced": "La progression n’est pas synchronisée, redémarrez la lecture", "ToastProviderCreatedFailed": "Échec de l’ajout du fournisseur", "ToastProviderCreatedSuccess": "Nouveau fournisseur ajouté", "ToastProviderNameAndUrlRequired": "Nom et URL requis", @@ -1027,6 +1052,7 @@ "ToastSessionCloseFailed": "Échec de la fermeture de la session", "ToastSessionDeleteFailed": "Échec de la suppression de session", "ToastSessionDeleteSuccess": "Session supprimée", + "ToastSleepTimerDone": "Minuterie de mise en veille terminée… zZzzZz", "ToastSlugMustChange": "L’identifiant d’URL contient des caractères invalides", "ToastSlugRequired": "L’identifiant d’URL est requis", "ToastSocketConnected": "WebSocket connecté", From 9bf46b6367a09c511edaf0eeb7b3989e075f7576 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Sun, 3 Nov 2024 14:05:56 +0000 Subject: [PATCH 360/539] Translated using Weblate (Spanish) Currently translated at 98.4% (1054 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/es.json b/client/strings/es.json index fea1a85f34..5ad3111a51 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -226,6 +226,7 @@ "LabelAllUsersExcludingGuests": "Todos los usuarios excepto invitados", "LabelAllUsersIncludingGuests": "Todos los usuarios e invitados", "LabelAlreadyInYourLibrary": "Ya existe en la Biblioteca", + "LabelApiToken": "Token de la API", "LabelAppend": "Adjuntar", "LabelAudioBitrate": "Tasa de bits del audio (por ejemplo, 128k)", "LabelAudioChannels": "Canales de audio (1 o 2)", From 4ad130a11a78b5fd06fcc0f39263ab0c1620ce0f Mon Sep 17 00:00:00 2001 From: thehijacker Date: Sat, 2 Nov 2024 18:21:27 +0000 Subject: [PATCH 361/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index b4e4383fd0..9966f7d95b 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -773,7 +773,7 @@ "MessageMarkAllEpisodesNotFinished": "Označi vse epizode kot nedokončane", "MessageMarkAsFinished": "Označi kot dokončano", "MessageMarkAsNotFinished": "Označi kot nedokončano", - "MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.", + "MessageMatchBooksDescription": "bo poskušalo povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.", "MessageNoAudioTracks": "Ni zvočnih posnetkov", "MessageNoAuthors": "Brez avtorjev", "MessageNoBackups": "Brez varnostnih kopij", @@ -902,7 +902,7 @@ "StatsBooksFinishedThisYear": "Nekaj knjig, ki so bile dokončane letos…", "StatsBooksListenedTo": "poslušanih knjig", "StatsCollectionGrewTo": "Vaša zbirka knjig se je povečala na …", - "StatsSessions": "sej", + "StatsSessions": "seje", "StatsSpentListening": "porabil za poslušanje", "StatsTopAuthor": "TOP AVTOR", "StatsTopAuthors": "TOP AVTORJI", From 7cbb1c60a277bab5daf259eaa49a65eb1f96095d Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Wed, 6 Nov 2024 10:22:29 +0000 Subject: [PATCH 362/539] Translated using Weblate (Ukrainian) Currently translated at 88.6% (949 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 97 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 4c1941d4e6..6005954d7d 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Обрати файли", "ButtonClearFilter": "Очистити фільтр", "ButtonCloseFeed": "Закрити стрічку", + "ButtonCloseSession": "Закрити відкритий сеанс", "ButtonCollections": "Добірки", "ButtonConfigureScanner": "Налаштувати сканер", "ButtonCreate": "Створити", @@ -28,6 +29,9 @@ "ButtonEdit": "Редагувати", "ButtonEditChapters": "Редагувати глави", "ButtonEditPodcast": "Редагувати подкаст", + "ButtonEnable": "Увімкнути", + "ButtonFireAndFail": "Вогонь і невдача", + "ButtonFireOnTest": "Випробування на вогнестійкість", "ButtonForceReScan": "Примусово сканувати", "ButtonFullPath": "Повний шлях", "ButtonHide": "Приховати", @@ -46,19 +50,23 @@ "ButtonNevermind": "Скасувати", "ButtonNext": "Наступний", "ButtonNextChapter": "Наступна глава", + "ButtonNextItemInQueue": "Наступний елемент у черзі", "ButtonOk": "Гаразд", "ButtonOpenFeed": "Відкрити стрічку", "ButtonOpenManager": "Відкрити менеджер", "ButtonPause": "Пауза", "ButtonPlay": "Слухати", + "ButtonPlayAll": "Відтворити все", "ButtonPlaying": "Відтворюється", "ButtonPlaylists": "Списки відтворення", "ButtonPrevious": "Попередній", "ButtonPreviousChapter": "Попередня глава", + "ButtonProbeAudioFile": "Перевірити аудіофайл", "ButtonPurgeAllCache": "Очистити весь кеш", "ButtonPurgeItemsCache": "Очистити кеш елементів", "ButtonQueueAddItem": "Додати до черги", "ButtonQueueRemoveItem": "Вилучити з черги", + "ButtonQuickEmbed": "Швидке вбудовування", "ButtonQuickEmbedMetadata": "Швидко вбудувати метадані", "ButtonQuickMatch": "Швидкий пошук", "ButtonReScan": "Пересканувати", @@ -92,6 +100,7 @@ "ButtonStats": "Статистика", "ButtonSubmit": "Надіслати", "ButtonTest": "Перевірити", + "ButtonUnlinkOpenId": "Вимкнути OpenID", "ButtonUpload": "Завантажити", "ButtonUploadBackup": "Завантажити резервну копію", "ButtonUploadCover": "Завантажити обкладинку", @@ -104,6 +113,7 @@ "ErrorUploadFetchMetadataNoResults": "Не вдалося отримати метадані — спробуйте оновити заголовок та/або автора", "ErrorUploadLacksTitle": "Назва обов'язкова", "HeaderAccount": "Профіль", + "HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих", "HeaderAdvanced": "Розширені", "HeaderAppriseNotificationSettings": "Налаштування сповіщень Apprise", "HeaderAudioTracks": "Аудіодоріжки", @@ -149,8 +159,11 @@ "HeaderMetadataToEmbed": "Вбудувати метадані", "HeaderNewAccount": "Новий профіль", "HeaderNewLibrary": "Нова бібліотека", + "HeaderNotificationCreate": "Створити сповіщення", + "HeaderNotificationUpdate": "Оновити сповіщення", "HeaderNotifications": "Сповіщення", "HeaderOpenIDConnectAuthentication": "Автентифікація OpenID Connect", + "HeaderOpenListeningSessions": "Відкриті сеанси прослуховування", "HeaderOpenRSSFeed": "Відкрити RSS-канал", "HeaderOtherFiles": "Інші файли", "HeaderPasswordAuthentication": "Автентифікація за паролем", @@ -168,6 +181,7 @@ "HeaderRemoveEpisodes": "Видалити епізодів: {0}", "HeaderSavedMediaProgress": "Збережений прогрес медіа", "HeaderSchedule": "Розклад", + "HeaderScheduleEpisodeDownloads": "Запланувати автоматичне завантаження епізодів", "HeaderScheduleLibraryScans": "Розклад автосканування бібліотеки", "HeaderSession": "Сеанс", "HeaderSetBackupSchedule": "Встановити розклад резервного копіювання", @@ -206,13 +220,18 @@ "LabelAddToPlaylist": "Додати до списку відтворення", "LabelAddToPlaylistBatch": "Додано елементів у список відтворення: {0}", "LabelAddedAt": "Дата додавання", + "LabelAddedDate": "Додано {0}", "LabelAdminUsersOnly": "Тільки для адміністраторів", "LabelAll": "Усе", "LabelAllUsers": "Усі користувачі", "LabelAllUsersExcludingGuests": "Усі, крім гостей", "LabelAllUsersIncludingGuests": "Усі, включно з гостями", "LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці", + "LabelApiToken": "Токен API", "LabelAppend": "Додати", + "LabelAudioBitrate": "Бітрейт аудіо (напр. 128k)", + "LabelAudioChannels": "Канали аудіо (1 або 2)", + "LabelAudioCodec": "Аудіокодек", "LabelAuthor": "Автор", "LabelAuthorFirstLast": "Автор (за ім'ям)", "LabelAuthorLastFirst": "Автор (за прізвищем)", @@ -225,6 +244,7 @@ "LabelAutoRegister": "Автореєстрація", "LabelAutoRegisterDescription": "Автоматично створювати нових користувачів після входу", "LabelBackToUser": "Повернутися до користувача", + "LabelBackupAudioFiles": "Резервне копіювання аудіофайлів", "LabelBackupLocation": "Розташування резервних копій", "LabelBackupsEnableAutomaticBackups": "Автоматичне резервне копіювання", "LabelBackupsEnableAutomaticBackupsHelp": "Резервні копії збережено у /metadata/backups", @@ -233,18 +253,22 @@ "LabelBackupsNumberToKeep": "Кількість резервних копій", "LabelBackupsNumberToKeepHelp": "Лиш 1 резервну копію буде видалено за раз, тож якщо їх багато, то вам варто видалити їх вручну.", "LabelBitrate": "Бітрейт", + "LabelBonus": "Бонус", "LabelBooks": "Книги", "LabelButtonText": "Текст кнопки", "LabelByAuthor": "від {0}", "LabelChangePassword": "Змінити пароль", "LabelChannels": "Канали", + "LabelChapterCount": "{0} Глав", "LabelChapterTitle": "Назва глави", "LabelChapters": "Глави", "LabelChaptersFound": "глав знайдено", "LabelClickForMoreInfo": "Натисніть, щоб дізнатися більше", + "LabelClickToUseCurrentValue": "Натисніть, щоб використати поточне значення", "LabelClosePlayer": "Закрити програвач", "LabelCodec": "Кодек", "LabelCollapseSeries": "Згорнути серії", + "LabelCollapseSubSeries": "Згорнути підсерії", "LabelCollection": "Добірка", "LabelCollections": "Добірки", "LabelComplete": "Завершити", @@ -290,13 +314,28 @@ "LabelEmailSettingsTestAddress": "Тестова адреса", "LabelEmbeddedCover": "Вбудована обкладинка", "LabelEnable": "Увімкнути", + "LabelEncodingBackupLocation": "Резервна копія ваших оригінальних аудіофайлів буде збережена в:", + "LabelEncodingChaptersNotEmbedded": "Глави не вбудовуються в багатодоріжкові аудіокниги.", + "LabelEncodingClearItemCache": "Переконайтесь, що періодично очищуєте кеш елементів.", + "LabelEncodingFinishedM4B": "Готовий M4B буде поміщений у вашу папку з аудіокнигами за адресою:", + "LabelEncodingInfoEmbedded": "Метадані будуть вбудовані в звукові доріжки всередині папки вашої аудіокниги.", + "LabelEncodingStartedNavigation": "Як тільки завдання розпочнеться, ви можете покинути цю сторінку.", + "LabelEncodingTimeWarning": "Кодування може зайняти до 30 хвилин.", + "LabelEncodingWarningAdvancedSettings": "Увага: не змінюйте ці налаштування, якщо ви не знайомі з параметрами кодування ffmpeg.", + "LabelEncodingWatcherDisabled": "Якщо у вас вимкнено спостереження за папкою, вам потрібно буде повторно відсканувати цю аудіокнигу.", "LabelEnd": "Кінець", "LabelEndOfChapter": "Кінець глави", "LabelEpisode": "Епізод", + "LabelEpisodeNotLinkedToRssFeed": "Епізод не прив'язаний до RSS-каналу", + "LabelEpisodeNumber": "Епізод #{0}", "LabelEpisodeTitle": "Назва епізоду", "LabelEpisodeType": "Тип епізоду", + "LabelEpisodeUrlFromRssFeed": "URL епізоду з RSS-каналу", + "LabelEpisodes": "Епізодов", + "LabelEpisodic": "Епізодичний", "LabelExample": "Приклад", "LabelExpandSeries": "Розгорнути серії", + "LabelExpandSubSeries": "Розгорнути підсерії", "LabelExplicit": "Відверта", "LabelExplicitChecked": "Відверта (з прапорцем)", "LabelExplicitUnchecked": "Не відверта (без прапорця)", @@ -305,7 +344,9 @@ "LabelFetchingMetadata": "Отримання метаданих", "LabelFile": "Файл", "LabelFileBirthtime": "Дата створення", + "LabelFileBornDate": "Народився {0}", "LabelFileModified": "Дата змінення", + "LabelFileModifiedDate": "Змінено {0}", "LabelFilename": "Ім'я файлу", "LabelFilterByUser": "Фільтрувати за користувачем", "LabelFindEpisodes": "Знайти епізоди", @@ -319,6 +360,7 @@ "LabelFontScale": "Розмір шрифту", "LabelFontStrikethrough": "Закреслений", "LabelFormat": "Формат", + "LabelFull": "Повний", "LabelGenre": "Жанр", "LabelGenres": "Жанри", "LabelHardDeleteFile": "Остаточно видалити файл", @@ -361,6 +403,7 @@ "LabelLess": "Менше", "LabelLibrariesAccessibleToUser": "Бібліотеки, доступні користувачу", "LabelLibrary": "Бібліотека", + "LabelLibraryFilterSublistEmpty": "Ні {0}", "LabelLibraryItem": "Елемент бібліотеки", "LabelLibraryName": "Назва бібліотеки", "LabelLimit": "Обмеження", @@ -373,6 +416,10 @@ "LabelLowestPriority": "Найнижчий пріоритет", "LabelMatchExistingUsersBy": "Шукати наявних користувачів за", "LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO", + "LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для завантаження. Використовуйте 0 для необмеженої кількості.", + "LabelMaxEpisodesToDownloadPerCheck": "Максимальна кількість нових епізодів для завантаження за перевірку", + "LabelMaxEpisodesToKeep": "Максимальна кількість епізодів для зберігання", + "LabelMaxEpisodesToKeepHelp": "Значення 0 не встановлює обмеження. Після автоматичного завантаження нового епізоду, буде видалено найстаріший епізод, якщо у вас більше ніж X епізодів. Видаляється лише 1 епізод за одне нове завантаження.", "LabelMediaPlayer": "Програвач медіа", "LabelMediaType": "Тип медіа", "LabelMetaTag": "Метатег", @@ -418,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають групами. Якщо налаштовано, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.", "LabelOpenRSSFeed": "Відкрити RSS-канал", "LabelOverwrite": "Перезаписати", + "LabelPaginationPageXOfY": "Сторінка {0} з {1}", "LabelPassword": "Пароль", "LabelPath": "Шлях", "LabelPermanent": "Постійний", "LabelPermissionsAccessAllLibraries": "Доступ до усіх бібліотек", "LabelPermissionsAccessAllTags": "Доступ до усіх міток", "LabelPermissionsAccessExplicitContent": "Доступ до відвертого вмісту", + "LabelPermissionsCreateEreader": "Можна створити читалку", "LabelPermissionsDelete": "Може видаляти", "LabelPermissionsDownload": "Може завантажувати", "LabelPermissionsUpdate": "Може оновлювати", @@ -431,6 +480,7 @@ "LabelPersonalYearReview": "Ваші підсумки року ({0})", "LabelPhotoPathURL": "Шлях/URL фото", "LabelPlayMethod": "Метод відтворення", + "LabelPlayerChapterNumberMarker": "{0} з {1}", "LabelPlaylists": "Списки відтворення", "LabelPodcast": "Подкаст", "LabelPodcastSearchRegion": "Регіон пошуку подкасту", @@ -442,8 +492,12 @@ "LabelPrimaryEbook": "Основна електронна книга", "LabelProgress": "Прогрес", "LabelProvider": "Джерело", + "LabelProviderAuthorizationValue": "Значення заголовка авторизації", "LabelPubDate": "Дата публікації", "LabelPublishYear": "Рік публікації", + "LabelPublishedDate": "Опубліковано {0}", + "LabelPublishedDecade": "Десятиліття публікації", + "LabelPublishedDecades": "Опубліковані десятиліття", "LabelPublisher": "Видавець", "LabelPublishers": "Видавці", "LabelRSSFeedCustomOwnerEmail": "Користувацька електронна адреса власника", @@ -463,21 +517,28 @@ "LabelRedo": "Повторити", "LabelRegion": "Регіон", "LabelReleaseDate": "Дата публікації", + "LabelRemoveAllMetadataAbs": "Видалити всі файли metadata.abs", + "LabelRemoveAllMetadataJson": "Видалити всі файли metadata.json", "LabelRemoveCover": "Видалити обкладинку", + "LabelRemoveMetadataFile": "Видалити файли метаданих у папках елементів бібліотеки", + "LabelRemoveMetadataFileHelp": "Видалити всі файли metadata.json та metadata.abs у ваших папках {0}.", "LabelRowsPerPage": "Рядків на сторінку", "LabelSearchTerm": "Пошуковий запит", "LabelSearchTitle": "Пошук за назвою", "LabelSearchTitleOrASIN": "Пошук назви або ASIN", "LabelSeason": "Сезон", + "LabelSeasonNumber": "Сезон #{0}", "LabelSelectAll": "Вибрати все", "LabelSelectAllEpisodes": "Вибрати всі серії", "LabelSelectEpisodesShowing": "Обрати показані епізоди: {0}", "LabelSelectUsers": "Вибрати користувачів", "LabelSendEbookToDevice": "Надіслати електронну книгу на...", "LabelSequence": "Послідовність", + "LabelSerial": "Серійний", "LabelSeries": "Серії", "LabelSeriesName": "Назва серії", "LabelSeriesProgress": "Прогрес серії", + "LabelServerLogLevel": "Рівень журналу сервера", "LabelServerYearReview": "Підсумки року сервера ({0})", "LabelSetEbookAsPrimary": "Зробити основною", "LabelSetEbookAsSupplementary": "Зробити додатковою", @@ -502,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Серії, що містять одну книгу, будуть приховані зі сторінки серій та полиць головної сторінки.", "LabelSettingsHomePageBookshelfView": "Полиці на головній сторінці", "LabelSettingsLibraryBookshelfView": "Показувати полиці у бібліотеці", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Відсоток виконання більше ніж", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Час, що залишився, менше ніж (секунди)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Позначити медіа-елемент як завершений, коли", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропускати попередні книги у Продовжити серії", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Полиця Продовжити серії на головній сторінці показує найпершу непочату книгу з тих серій, у яких ви завершили хоча б одну книгу та не маєте книг у процесі. Якщо увімкнути це налаштування, то серії продовжуватимуться з останньої завершеної книги, а не з першої непочатої.", "LabelSettingsParseSubtitles": "Дістати підзаголовки", @@ -566,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} хвилини", "LabelTimeDurationXSeconds": "{0} секунди", "LabelTimeInMinutes": "Час у хвилинах", + "LabelTimeLeft": "{0} залишилось", "LabelTimeListened": "Часу прослухано", "LabelTimeListenedToday": "Сьогодні прослухано", "LabelTimeRemaining": "Лишилося: {0}", @@ -573,6 +638,7 @@ "LabelTitle": "Назва", "LabelToolsEmbedMetadata": "Вбудувати метадані", "LabelToolsEmbedMetadataDescription": "Вбудувати метадані в аудіофайли, включно з обкладинками та главами.", + "LabelToolsM4bEncoder": "Кодувальник M4B", "LabelToolsMakeM4b": "Створити M4B-файл аудіокниги", "LabelToolsMakeM4bDescription": "Створити .M4B-аудіокнигу з вбудованими метаданими, обкладинкою та главами.", "LabelToolsSplitM4b": "Розділити M4B на MP3", @@ -585,10 +651,12 @@ "LabelTracksMultiTrack": "Декілька доріжок", "LabelTracksNone": "Доріжки відсутні", "LabelTracksSingleTrack": "Одна доріжка", + "LabelTrailer": "Трейлер", "LabelType": "Тип", "LabelUnabridged": "Повна", "LabelUndo": "Скасувати", "LabelUnknown": "Невідомо", + "LabelUnknownPublishDate": "Невідома дата публікації", "LabelUpdateCover": "Оновити обкладинку", "LabelUpdateCoverHelp": "Дозволити перезапис наявних обкладинок обраних книг після віднайдення", "LabelUpdateDetails": "Оновити подробиці", @@ -597,8 +665,10 @@ "LabelUploaderDragAndDrop": "Перетягніть файли або теки", "LabelUploaderDropFiles": "Перетягніть файли", "LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію", + "LabelUseAdvancedOptions": "Використовувати розширені налаштування", "LabelUseChapterTrack": "Прогрес глави", "LabelUseFullTrack": "Використовувати доріжку повністю", + "LabelUseZeroForUnlimited": "Використовуйте 0 для необмеженої кількості", "LabelUser": "Користувач", "LabelUsername": "Ім’я користувача", "LabelValue": "Значення", @@ -637,19 +707,27 @@ "MessageCheckingCron": "Перевірка планувальника...", "MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?", "MessageConfirmDeleteBackup": "Ви дійсно бажаєте видалити резервну копію за {0}?", + "MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?", "MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?", "MessageConfirmDeleteLibrary": "Ви дійсно бажаєте назавжди видалити бібліотеку \"{0}\"?", "MessageConfirmDeleteLibraryItem": "Елемент бібліотеки буде видалено з бази даних та вашої файлової системи. Ви впевнені?", "MessageConfirmDeleteLibraryItems": "З бази даних та вашої файлової системи будуть видалені елементи бібліотеки: {0}. Ви впевнені?", + "MessageConfirmDeleteMetadataProvider": "Ви впевнені, що хочете видалити користувацького постачальника метаданих \"{0}\"?", + "MessageConfirmDeleteNotification": "Ви впевнені, що хочете видалити це сповіщення?", "MessageConfirmDeleteSession": "Ви дійсно бажаєте видалити цей сеанс?", + "MessageConfirmEmbedMetadataInAudioFiles": "Ви впевнені, що хочете вставити метадані в {0} аудіофайлів?", "MessageConfirmForceReScan": "Ви дійсно бажаєте примусово пересканувати?", "MessageConfirmMarkAllEpisodesFinished": "Ви дійсно бажаєте позначити усі епізоди завершеними?", "MessageConfirmMarkAllEpisodesNotFinished": "Ви дійсно бажаєте позначити усі епізоди незавершеними?", + "MessageConfirmMarkItemFinished": "Ви впевнені, що хочете позначити \"{0}\" як завершене?", + "MessageConfirmMarkItemNotFinished": "Ви впевнені, що хочете позначити \"{0}\" як незавершене?", "MessageConfirmMarkSeriesFinished": "Ви дійсно бажаєте позначити усі книги серії завершеними?", "MessageConfirmMarkSeriesNotFinished": "Ви дійсно бажаєте позначити всі книги серії незавершеними?", + "MessageConfirmNotificationTestTrigger": "Активувати це сповіщення з тестовими даними?", "MessageConfirmPurgeCache": "Очищення кешу видалить усю теку /metadata/cache.

    Ви дійсно бажаєте видалити теку кешу?", "MessageConfirmPurgeItemsCache": "Очищення кешу елементів видалить усю теку /metadata/cache/items.
    Ви певні?", "MessageConfirmQuickEmbed": "Увага! Швидке вбудування не створює резервних копій ваших аудіо. Переконайтеся, що маєте копію ваших файлів.

    Продовжити?", + "MessageConfirmQuickMatchEpisodes": "При виявленні співпадінь інформація про епізоди швидкого пошуку буде перезаписана. Будуть оновлені тільки несуперечливі епізоди. Ви впевнені?", "MessageConfirmReScanLibraryItems": "Ви дійсно бажаєте пересканувати елементи: {0}?", "MessageConfirmRemoveAllChapters": "Ви дійсно бажаєте видалити усі глави?", "MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?", @@ -657,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?", "MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?", "MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?", + "MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?", "MessageConfirmRemoveNarrator": "Ви дійсно бажаєте видалити читця \"{0}\"?", "MessageConfirmRemovePlaylist": "Ви дійсно бажаєте видалити список відтворення \"{0}\"?", "MessageConfirmRenameGenre": "Ви дійсно бажаєте замінити жанр \"{0}\" на \"{1}\" для усіх елементів?", @@ -665,11 +744,14 @@ "MessageConfirmRenameTag": "Ви дійсно бажаєте замінити мітку \"{0}\" на \"{1}\" для усіх елементів?", "MessageConfirmRenameTagMergeNote": "Примітка: така мітка вже існує, тож їх буде об'єднано.", "MessageConfirmRenameTagWarning": "Увага! Вже існує схожа мітка у іншому регістрі \"{0}\".", + "MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?", "MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?", + "MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?", "MessageDownloadingEpisode": "Завантаження епізоду", "MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку", "MessageEmbedFailed": "Не вдалося вбудувати!", "MessageEmbedFinished": "Вбудовано!", + "MessageEmbedQueue": "В черзі на вбудовування метаданих ({0} в черзі)", "MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}", "MessageEreaderDevices": "Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.", "MessageFeedURLWillBe": "URL-адреса каналу буде {0}", @@ -700,6 +782,7 @@ "MessageNoCollections": "Добірки відсутні", "MessageNoCoversFound": "Обкладинок не знайдено", "MessageNoDescription": "Без опису", + "MessageNoDevices": "Немає пристроїв", "MessageNoDownloadsInProgress": "Немає активних завантажень", "MessageNoDownloadsQueued": "Немає завантажень у черзі", "MessageNoEpisodeMatchesFound": "Відповідних епізодів не знайдено", @@ -713,6 +796,7 @@ "MessageNoLogs": "Журнал порожній", "MessageNoMediaProgress": "Прогрес відсутній", "MessageNoNotifications": "Сповіщення відсутні", + "MessageNoPodcastFeed": "Невірний подкаст: Немає каналу", "MessageNoPodcastsFound": "Подкастів не знайдено", "MessageNoResults": "Немає результатів", "MessageNoSearchResultsFor": "Немає результатів пошуку для \"{0}\"", @@ -727,7 +811,12 @@ "MessagePauseChapter": "Призупинити відтворення глави", "MessagePlayChapter": "Слухати початок глави", "MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки", + "MessagePleaseWait": "Будь ласка, зачекайте...", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку", + "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS фіду", + "MessageQuickEmbedInProgress": "Швидке вбудовування в процесі", + "MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)", + "MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів", "MessageQuickMatchDescription": "Заповнити відсутні подробиці та обкладинку першим результатом пошуку '{0}'. Не перезаписує подробиці, якщо не увімкнено параметр \"Надавати перевагу віднайденим метаданим\".", "MessageRemoveChapter": "Видалити главу", "MessageRemoveEpisodes": "Видалити епізодів: {0}", @@ -745,6 +834,14 @@ "MessageShareExpiresIn": "Сплине за {0}", "MessageShareURLWillBe": "Поширюваний URL - {0}", "MessageStartPlaybackAtTime": "Почати відтворення \"{0}\" з {1}?", + "MessageTaskAudioFileNotWritable": "Аудіофайл \"{0}\" недоступний для запису", + "MessageTaskCanceledByUser": "Задача скасована користувачем", + "MessageTaskDownloadingEpisodeDescription": "Завантаження епізоду \"{0}\"", + "MessageTaskEmbeddingMetadata": "Вбудовування метаданих", + "MessageTaskEmbeddingMetadataDescription": "Вбудовування метаданих у аудіокнигу \"{0}\"", + "MessageTaskEncodingM4b": "Кодування M4B", + "MessageTaskEncodingM4bDescription": "Кодування аудіокниги \"{0}\" в один файл m4b", + "MessageTaskFailed": "Неуспішно", "MessageThinking": "Думаю…", "MessageUploaderItemFailed": "Не вдалося завантажити", "MessageUploaderItemSuccess": "Успішно завантажено!", From cc42aa32efda563ea7c530e53eb780e959e56232 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Wed, 6 Nov 2024 07:36:43 +0000 Subject: [PATCH 363/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 9966f7d95b..e3320ea877 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -625,7 +625,7 @@ "LabelTheme": "Tema", "LabelThemeDark": "Temna", "LabelThemeLight": "Svetla", - "LabelTimeBase": "Odvisna od časa", + "LabelTimeBase": "Osnovni čas", "LabelTimeDurationXHours": "{0} ur", "LabelTimeDurationXMinutes": "{0} minut", "LabelTimeDurationXSeconds": "{0} sekund", From 023ceed2869d0db65fb1052e57ed21dc48dc07e5 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Thu, 7 Nov 2024 09:09:11 +0000 Subject: [PATCH 364/539] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 124 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 6005954d7d..668ccd33e7 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -842,6 +842,33 @@ "MessageTaskEncodingM4b": "Кодування M4B", "MessageTaskEncodingM4bDescription": "Кодування аудіокниги \"{0}\" в один файл m4b", "MessageTaskFailed": "Неуспішно", + "MessageTaskFailedToBackupAudioFile": "Не вдалося створити резервну копію аудіофайлу \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Не вдалося створити каталог кешу", + "MessageTaskFailedToEmbedMetadataInFile": "Не вдалося вбудувати метадані у файл \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Не вдалося об’єднати аудіофайли", + "MessageTaskFailedToMoveM4bFile": "Не вдалося перемістити файл m4b", + "MessageTaskFailedToWriteMetadataFile": "Не вдалося записати файл метаданих", + "MessageTaskMatchingBooksInLibrary": "Відповідність книг у бібліотеці \"{0}\"", + "MessageTaskNoFilesToScan": "Немає файлів для сканування", + "MessageTaskOpmlImport": "Імпорт OPML", + "MessageTaskOpmlImportDescription": "Створення подкастів з {0} RSS-стрічок", + "MessageTaskOpmlImportFeed": "Канал імпорту OPML", + "MessageTaskOpmlImportFeedDescription": "Імпорт RSS-каналу \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "Не вдалося отримати подкаст-стрічку", + "MessageTaskOpmlImportFeedPodcastDescription": "Створення подкасту \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "Подкаст вже існує за цим шляхом", + "MessageTaskOpmlImportFeedPodcastFailed": "Не вдалося створити подкаст", + "MessageTaskOpmlImportFinished": "Додано {0} подкастів", + "MessageTaskOpmlParseFailed": "Не вдалося розібрати файл OPML", + "MessageTaskOpmlParseFastFail": "Невірний файл OPML: не знайдено тег або тег ", + "MessageTaskOpmlParseNoneFound": "У файлі OPML не знайдено жодного канала", + "MessageTaskScanItemsAdded": "{0} додано", + "MessageTaskScanItemsMissing": "{0} відсутній", + "MessageTaskScanItemsUpdated": "{0} оновлено", + "MessageTaskScanNoChangesNeeded": "Змін не потрібно", + "MessageTaskScanningFileChanges": "Сканування змін файлів у \"{0}\"", + "MessageTaskScanningLibrary": "Сканування бібліотеки \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "Цільовий каталог недоступний для запису", "MessageThinking": "Думаю…", "MessageUploaderItemFailed": "Не вдалося завантажити", "MessageUploaderItemSuccess": "Успішно завантажено!", @@ -859,6 +886,10 @@ "NoteUploaderFoldersWithMediaFiles": "Теки з медіафайлами буде оброблено як окремі елементи бібліотеки.", "NoteUploaderOnlyAudioFiles": "Якщо завантажувати лише аудіофайли, то кожен файл буде оброблено як окрему книгу.", "NoteUploaderUnsupportedFiles": "Непідтримувані файли пропущено. Під час вибору або перетягування теки, файли, що знаходяться поза текою, пропускаються.", + "NotificationOnBackupCompletedDescription": "Запускається після завершення резервного копіювання", + "NotificationOnBackupFailedDescription": "Срабатывает при збої резервного копіювання", + "NotificationOnEpisodeDownloadedDescription": "Запускається при автоматичному завантаженні епізоду подкасту", + "NotificationOnTestDescription": "Подія для тестування системи сповіщень", "PlaceholderNewCollection": "Нова назва добірки", "PlaceholderNewFolderPath": "Новий шлях до теки", "PlaceholderNewPlaylist": "Нова назва списку", @@ -883,17 +914,29 @@ "StatsTotalDuration": "Загальною довжиною…", "StatsYearInReview": "ОГЛЯД РОКУ", "ToastAccountUpdateSuccess": "Профіль оновлено", + "ToastAppriseUrlRequired": "Необхідно ввести URL для Apprise", + "ToastAsinRequired": "ASIN є обов'язковим", "ToastAuthorImageRemoveSuccess": "Фото автора видалено", + "ToastAuthorNotFound": "Автор \"{0}\" не знайдений", + "ToastAuthorRemoveSuccess": "Автор видалений", + "ToastAuthorSearchNotFound": "Автор не знайдений", "ToastAuthorUpdateMerged": "Автора об'єднано", "ToastAuthorUpdateSuccess": "Автора оновлено", "ToastAuthorUpdateSuccessNoImageFound": "Автора оновлено (фото не знайдено)", + "ToastBackupAppliedSuccess": "Резервна копія застосована", "ToastBackupCreateFailed": "Не вдалося створити резервну копію", "ToastBackupCreateSuccess": "Резервну копію створено", "ToastBackupDeleteFailed": "Не вдалося видалити резервну копію", "ToastBackupDeleteSuccess": "Резервну копію видалено", + "ToastBackupInvalidMaxKeep": "Невірна кількість резервних копій для зберігання", + "ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії", "ToastBackupRestoreFailed": "Не вдалося відновити резервну копію", "ToastBackupUploadFailed": "Не вдалося завантажити резервну копію", "ToastBackupUploadSuccess": "Резервну копію завантажено", + "ToastBatchDeleteFailed": "Помилка при пакетному видаленні", + "ToastBatchDeleteSuccess": "Пакетне видалення успішне", + "ToastBatchQuickMatchFailed": "Не вдалося виконати пакетне швидке співпадіння!", + "ToastBatchQuickMatchStarted": "Пакетне швидке співпадіння {0} книг розпочато!", "ToastBatchUpdateFailed": "Не вдалося оновити обрані", "ToastBatchUpdateSuccess": "Обрані успішно оновлено", "ToastBookmarkCreateFailed": "Не вдалося створити закладку", @@ -904,19 +947,43 @@ "ToastCachePurgeSuccess": "Кеш очищено", "ToastChaptersHaveErrors": "Глави містять помилки", "ToastChaptersMustHaveTitles": "Глави повинні мати назви", + "ToastChaptersRemoved": "Розділи видалені", + "ToastChaptersUpdated": "Розділи оновлені", + "ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції", + "ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції", "ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки", "ToastCollectionRemoveSuccess": "Добірку видалено", "ToastCollectionUpdateSuccess": "Добірку оновлено", + "ToastCoverUpdateFailed": "Не вдалося оновити обкладинку", "ToastDeleteFileFailed": "Не вдалося видалити файл", "ToastDeleteFileSuccess": "Файл видалено", + "ToastDeviceAddFailed": "Не вдалося додати пристрій", + "ToastDeviceNameAlreadyExists": "Пристрій для електронних книг з таким ім'ям вже існує", + "ToastDeviceTestEmailFailed": "Не вдалося надіслати тестовий електронний лист", + "ToastDeviceTestEmailSuccess": "Тестовий електронний лист надіслано", + "ToastEmailSettingsUpdateSuccess": "Налаштування електронної пошти оновлено", + "ToastEncodeCancelFailed": "Не вдалося скасувати кодування", + "ToastEncodeCancelSucces": "Кодування скасовано", + "ToastEpisodeDownloadQueueClearFailed": "Не вдалося очистити чергу", + "ToastEpisodeDownloadQueueClearSuccess": "Чергу на завантаження епізодів очищено", + "ToastEpisodeUpdateSuccess": "{0} епізодів оновлено", "ToastErrorCannotShare": "Не можна типово поширити на цей пристрій", "ToastFailedToLoadData": "Не вдалося завантажити дані", + "ToastFailedToMatch": "Не вдалося знайти відповідність", + "ToastFailedToShare": "Не вдалося поділитися", + "ToastFailedToUpdate": "Не вдалося оновити", + "ToastInvalidImageUrl": "Невірний URL зображення", + "ToastInvalidMaxEpisodesToDownload": "Невірна кількість епізодів для завантаження", + "ToastInvalidUrl": "Невірний URL", "ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено", + "ToastItemDeletedFailed": "Не вдалося видалити елемент", + "ToastItemDeletedSuccess": "Видалений елемент", "ToastItemDetailsUpdateSuccess": "Подробиці про елемент оновлено", "ToastItemMarkedAsFinishedFailed": "Не вдалося позначити як завершене", "ToastItemMarkedAsFinishedSuccess": "Елемент позначено як завершений", "ToastItemMarkedAsNotFinishedFailed": "Не вдалося позначити незавершеним", "ToastItemMarkedAsNotFinishedSuccess": "Елемент позначено незавершеним", + "ToastItemUpdateSuccess": "Елемент оновлено", "ToastLibraryCreateFailed": "Не вдалося створити бібліотеку", "ToastLibraryCreateSuccess": "Бібліотеку \"{0}\" створено", "ToastLibraryDeleteFailed": "Не вдалося видалити бібліотеку", @@ -924,28 +991,83 @@ "ToastLibraryScanFailedToStart": "Не вдалося розпочати сканування", "ToastLibraryScanStarted": "Почалося сканування бібліотеки", "ToastLibraryUpdateSuccess": "Бібліотеку \"{0}\" оновлено", + "ToastMatchAllAuthorsFailed": "Не вдалось знайти відповідності з усіма авторами", + "ToastMetadataFilesRemovedError": "Помилка при видаленні metadata.{0} файли", + "ToastMetadataFilesRemovedNoneFound": "У бібліотеці не знайдено metadata.{0} файлів", + "ToastMetadataFilesRemovedNoneRemoved": "Не видалено metadata.{0} файлів", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлів видалено", + "ToastMustHaveAtLeastOnePath": "Повинен бути хоча б один шлях", + "ToastNameEmailRequired": "Ім'я та електронна пошта обов'язкові", + "ToastNameRequired": "Ім'я обов'язкове", + "ToastNewEpisodesFound": "{0} нових епізодів знайдено", + "ToastNewUserCreatedFailed": "Не вдалося створити акаунт: \"{0}\"", + "ToastNewUserCreatedSuccess": "Новий акаунт створено", + "ToastNewUserLibraryError": "Потрібно вибрати хоча б одну бібліотеку", + "ToastNewUserPasswordError": "Пароль обов'язковий, лише користувач з правами root може мати порожній пароль", + "ToastNewUserTagError": "Потрібно вибрати хоча б один тег", + "ToastNewUserUsernameError": "Введіть ім'я користувача", + "ToastNoNewEpisodesFound": "Нових епізодів не знайдено", + "ToastNoUpdatesNecessary": "Оновлення не потрібні", + "ToastNotificationCreateFailed": "Не вдалося створити сповіщення", + "ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення", + "ToastNotificationFailedMaximum": "Максимальна кількість невдалих спроб повинна бути >= 0", + "ToastNotificationQueueMaximum": "Максимальна кількість сповіщень у черзі повинна бути >= 0", + "ToastNotificationSettingsUpdateSuccess": "Налаштування сповіщень оновлено", + "ToastNotificationTestTriggerFailed": "Не вдалося ініціювати тестове сповіщення", + "ToastNotificationTestTriggerSuccess": "Спрацьовувало сповіщення про тестування", + "ToastNotificationUpdateSuccess": "Сповіщення оновлено", "ToastPlaylistCreateFailed": "Не вдалося створити список", "ToastPlaylistCreateSuccess": "Список відтворення створено", "ToastPlaylistRemoveSuccess": "Список відтворення видалено", "ToastPlaylistUpdateSuccess": "Список відтворення оновлено", "ToastPodcastCreateFailed": "Не вдалося створити подкаст", "ToastPodcastCreateSuccess": "Подкаст успішно створено", + "ToastPodcastGetFeedFailed": "Не вдалося отримати фід подкасту", + "ToastPodcastNoEpisodesInFeed": "У RSS-каналі не знайдено епізодів", + "ToastPodcastNoRssFeed": "Подкаст не має RSS-каналу", + "ToastProgressIsNotBeingSynced": "Прогрес не синхронізується, перезапустіть відтворення", + "ToastProviderCreatedFailed": "Не вдалося додати постачальника", + "ToastProviderCreatedSuccess": "Новий постачальник доданий", + "ToastProviderNameAndUrlRequired": "Ім'я та URL обов'язкові", + "ToastProviderRemoveSuccess": "Постачальник видалений", "ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал", "ToastRSSFeedCloseSuccess": "RSS-канал закрито", + "ToastRemoveFailed": "Не вдалося видалити", "ToastRemoveItemFromCollectionFailed": "Не вдалося видалити елемент із добірки", "ToastRemoveItemFromCollectionSuccess": "Елемент видалено з добірки", + "ToastRemoveItemsWithIssuesFailed": "Не вдалося видалити елементи бібліотеки з проблемами", + "ToastRemoveItemsWithIssuesSuccess": "Видалено елементи бібліотеки з проблемами", + "ToastRenameFailed": "Не вдалося перейменувати", + "ToastRescanFailed": "Не вдалося повторно сканувати для {0}", + "ToastRescanRemoved": "Повторне сканування завершено, елемент був видалений", + "ToastRescanUpToDate": "Повторне сканування завершено, елемент актуальний", + "ToastRescanUpdated": "Повторне сканування завершено, елемент оновлено", + "ToastScanFailed": "Не вдалося сканувати елемент бібліотеки", + "ToastSelectAtLeastOneUser": "Виберіть хоча б одного користувача", "ToastSendEbookToDeviceFailed": "Не вдалося надіслати електронну книгу на пристрій", "ToastSendEbookToDeviceSuccess": "Електронну книгу надіслано на пристрій \"{0}\"", "ToastSeriesUpdateFailed": "Не вдалося оновити серію", "ToastSeriesUpdateSuccess": "Серію успішно оновлено", "ToastServerSettingsUpdateSuccess": "Налаштування сервера оновлено", + "ToastSessionCloseFailed": "Не вдалося закрити сесію", "ToastSessionDeleteFailed": "Не вдалося видалити сесію", "ToastSessionDeleteSuccess": "Сесію видалено", + "ToastSleepTimerDone": "Час сну завершено... зЗзЗз", + "ToastSlugMustChange": "Slug містить недопустимі символи", + "ToastSlugRequired": "Slug обов'язковий", "ToastSocketConnected": "Сокет під'єднано", "ToastSocketDisconnected": "Сокет від'єднано", "ToastSocketFailedToConnect": "Не вдалося під'єднатися до сокета", "ToastSortingPrefixesEmptyError": "Мусить мати хоча б 1 префікс сортування", "ToastSortingPrefixesUpdateSuccess": "Префікси сортування оновлено ({0})", + "ToastTitleRequired": "Заголовок обов'язковий", + "ToastUnknownError": "Невідома помилка", + "ToastUnlinkOpenIdFailed": "Не вдалося відв'язати користувача від OpenID", + "ToastUnlinkOpenIdSuccess": "Користувача відв'язано від OpenID", "ToastUserDeleteFailed": "Не вдалося видалити користувача", - "ToastUserDeleteSuccess": "Користувача видалено" + "ToastUserDeleteSuccess": "Користувача видалено", + "ToastUserPasswordChangeSuccess": "Пароль успішно змінено", + "ToastUserPasswordMismatch": "Паролі не збігаються", + "ToastUserPasswordMustChange": "Новий пароль не може співпадати з попереднім", + "ToastUserRootRequireName": "Потрібно ввести ім'я користувача root" } From 876fcf3296e5e3c8348966068d8351fe7fbd14e3 Mon Sep 17 00:00:00 2001 From: Languages add-on Date: Fri, 8 Nov 2024 02:12:28 +0100 Subject: [PATCH 365/539] Added translation using Weblate (Arabic) --- client/strings/ar.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 client/strings/ar.json diff --git a/client/strings/ar.json b/client/strings/ar.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/client/strings/ar.json @@ -0,0 +1 @@ +{} From ec4c4a4d5aaf245f312ca9a3b4da096e4350bd00 Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Sun, 10 Nov 2024 21:32:17 +0000 Subject: [PATCH 366/539] Translated using Weblate (Czech) Currently translated at 83.4% (894 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index 7a898a3708..40b8553fee 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Aktualizovat notifikaci", "HeaderNotifications": "Oznámení", "HeaderOpenIDConnectAuthentication": "Ověřování pomocí OpenID Connect", + "HeaderOpenListeningSessions": "Otevřené relace přehrávače", "HeaderOpenRSSFeed": "Otevřít RSS kanál", "HeaderOtherFiles": "Ostatní soubory", "HeaderPasswordAuthentication": "Autentizace heslem", @@ -258,6 +259,7 @@ "LabelByAuthor": "od {0}", "LabelChangePassword": "Změnit heslo", "LabelChannels": "Kanály", + "LabelChapterCount": "{0} Kapitol", "LabelChapterTitle": "Název kapitoly", "LabelChapters": "Kapitoly", "LabelChaptersFound": "Kapitoly nalezeny", From 12c2071358f222d882e6e2da6854aa9a41851f53 Mon Sep 17 00:00:00 2001 From: Pavel Vachek Date: Sun, 10 Nov 2024 21:30:41 +0000 Subject: [PATCH 367/539] Translated using Weblate (Czech) Currently translated at 83.4% (894 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index 40b8553fee..0dd6367c8b 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -67,7 +67,7 @@ "ButtonQueueAddItem": "Přidat do fronty", "ButtonQueueRemoveItem": "Odstranit z fronty", "ButtonQuickEmbed": "Rychle Zapsat", - "ButtonQuickEmbedMetadata": "Rychle Zapsat Metadata", + "ButtonQuickEmbedMetadata": "Rychle zapsat Metadata", "ButtonQuickMatch": "Rychlé přiřazení", "ButtonReScan": "Znovu prohledat", "ButtonRead": "Číst", From bb6377fb22dac62aea9e30a51a7c52dfabaeb6d3 Mon Sep 17 00:00:00 2001 From: Nicholas W Date: Tue, 12 Nov 2024 04:15:18 +0100 Subject: [PATCH 368/539] Deleted translation using Weblate (English (United States)) --- client/strings/en_US.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 client/strings/en_US.json diff --git a/client/strings/en_US.json b/client/strings/en_US.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/client/strings/en_US.json +++ /dev/null @@ -1 +0,0 @@ -{} From 3f0347253ef748a0c2c288e6a9750c21802f4d47 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Mon, 11 Nov 2024 10:46:15 +0000 Subject: [PATCH 369/539] Translated using Weblate (Spanish) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index 5ad3111a51..d9e24723c7 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Notificación de actualización", "HeaderNotifications": "Notificaciones", "HeaderOpenIDConnectAuthentication": "Autenticación OpenID Connect", + "HeaderOpenListeningSessions": "Sesiones públicas de escucha", "HeaderOpenRSSFeed": "Abrir fuente RSS", "HeaderOtherFiles": "Otros Archivos", "HeaderPasswordAuthentication": "Autenticación por contraseña", @@ -815,6 +816,7 @@ "MessagePodcastSearchField": "Introduzca el término de búsqueda o la URL de la fuente RSS", "MessageQuickEmbedInProgress": "Integración rápida en proceso", "MessageQuickEmbedQueue": "En cola para inserción rápida ({0} en cola)", + "MessageQuickMatchAllEpisodes": "Combina rápidamente todos los episodios", "MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la opción \"Preferir Metadatos Encontrados\" del servidor esté habilitada.", "MessageRemoveChapter": "Remover capítulos", "MessageRemoveEpisodes": "Remover {0} episodio(s)", @@ -933,6 +935,8 @@ "ToastBackupUploadSuccess": "Respaldo cargado", "ToastBatchDeleteFailed": "Error al eliminar por lotes", "ToastBatchDeleteSuccess": "Borrado por lotes correcto", + "ToastBatchQuickMatchFailed": "¡Error en la sincronización rápida por lotes!", + "ToastBatchQuickMatchStarted": "¡Se inició el lote de búsqueda rápida de {0} libros!", "ToastBatchUpdateFailed": "Subida masiva fallida", "ToastBatchUpdateSuccess": "Subida masiva exitosa", "ToastBookmarkCreateFailed": "Error al crear marcador", @@ -944,6 +948,7 @@ "ToastChaptersHaveErrors": "Los capítulos tienen errores", "ToastChaptersMustHaveTitles": "Los capítulos tienen que tener un título", "ToastChaptersRemoved": "Capítulos eliminados", + "ToastChaptersUpdated": "Capítulos actualizados", "ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)", "ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente", "ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección", @@ -961,11 +966,14 @@ "ToastEncodeCancelSucces": "Codificación cancelada", "ToastEpisodeDownloadQueueClearFailed": "No se pudo borrar la cola", "ToastEpisodeDownloadQueueClearSuccess": "Se borró la cola de descargas de los episodios", + "ToastEpisodeUpdateSuccess": "{0} episodio(s) actualizado(s)", "ToastErrorCannotShare": "No se puede compartir de forma nativa en este dispositivo", "ToastFailedToLoadData": "Error al cargar data", + "ToastFailedToMatch": "Error al emparejar", "ToastFailedToShare": "Error al compartir", "ToastFailedToUpdate": "Error al actualizar", "ToastInvalidImageUrl": "URL de la imagen no válida", + "ToastInvalidMaxEpisodesToDownload": "Número máximo de episodios para descargar no válidos", "ToastInvalidUrl": "URL no válida", "ToastItemCoverUpdateSuccess": "Portada del elemento actualizada", "ToastItemDeletedFailed": "Error al eliminar el elemento", @@ -984,14 +992,21 @@ "ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca", "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada", "ToastMatchAllAuthorsFailed": "No coincide con todos los autores", + "ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)", + "ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca", + "ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)", + "ToastMetadataFilesRemovedSuccess": "{0} metadatos.{1} archivos eliminados", + "ToastMustHaveAtLeastOnePath": "Debe tener al menos una ruta", "ToastNameEmailRequired": "Son obligatorios el nombre y el correo electrónico", "ToastNameRequired": "Nombre obligatorio", + "ToastNewEpisodesFound": "{0} nuevo(s) episodio(s) encontrado(s)", "ToastNewUserCreatedFailed": "Error al crear la cuenta: \"{0}\"", "ToastNewUserCreatedSuccess": "Nueva cuenta creada", "ToastNewUserLibraryError": "Debes seleccionar al menos una biblioteca", "ToastNewUserPasswordError": "Debes tener una contraseña, solo el usuario root puede estar sin contraseña", "ToastNewUserTagError": "Debes seleccionar al menos una etiqueta", "ToastNewUserUsernameError": "Introduce un nombre de usuario", + "ToastNoNewEpisodesFound": "No se encontraron nuevos episodios", "ToastNoUpdatesNecessary": "No es necesario actualizar", "ToastNotificationCreateFailed": "Error al crear notificación", "ToastNotificationDeleteFailed": "Error al borrar la notificación", @@ -1010,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "No se puede obtener el podcast", "ToastPodcastNoEpisodesInFeed": "No se han encontrado episodios en el feed del RSS", "ToastPodcastNoRssFeed": "El podcast no tiene feed RSS", + "ToastProgressIsNotBeingSynced": "El progreso no se sincroniza, reinicia la reproducción", "ToastProviderCreatedFailed": "Error al añadir el proveedor", "ToastProviderCreatedSuccess": "Nuevo proveedor añadido", "ToastProviderNameAndUrlRequired": "Nombre y Url obligatorios", @@ -1036,6 +1052,7 @@ "ToastSessionCloseFailed": "Error al cerrar la sesión", "ToastSessionDeleteFailed": "Error al eliminar sesión", "ToastSessionDeleteSuccess": "Sesión eliminada", + "ToastSleepTimerDone": "Temporizador de apagado automático activado... zZzzZz", "ToastSlugMustChange": "El slug contiene caracteres no válidos", "ToastSlugRequired": "Slug obligatorio", "ToastSocketConnected": "Socket conectado", From 2dd30c7a26d632bfaf0d3acb8c8fe1d59d97fcab Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Mon, 11 Nov 2024 11:52:00 +0000 Subject: [PATCH 370/539] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 71.3% (764 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hant/ --- client/strings/zh-tw.json | 54 +++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/client/strings/zh-tw.json b/client/strings/zh-tw.json index f6c4b6c8cf..080d1bac04 100644 --- a/client/strings/zh-tw.json +++ b/client/strings/zh-tw.json @@ -1,5 +1,5 @@ { - "ButtonAdd": "增加", + "ButtonAdd": "添加", "ButtonAddChapters": "新增章節", "ButtonAddDevice": "新增設備", "ButtonAddLibrary": "新增庫", @@ -17,7 +17,7 @@ "ButtonCheckAndDownloadNewEpisodes": "檢查並下載新劇集", "ButtonChooseAFolder": "選擇資料夾", "ButtonChooseFiles": "選擇檔案", - "ButtonClearFilter": "清除過濾器", + "ButtonClearFilter": "清楚過濾器", "ButtonCloseFeed": "關閉源", "ButtonCloseSession": "關閉開放會話", "ButtonCollections": "收藏", @@ -35,6 +35,8 @@ "ButtonHide": "隱藏", "ButtonHome": "首頁", "ButtonIssues": "問題", + "ButtonJumpBackward": "向後跳轉", + "ButtonJumpForward": "向前跳轉", "ButtonLatest": "最新", "ButtonLibrary": "媒體庫", "ButtonLogout": "登出", @@ -53,6 +55,7 @@ "ButtonPlay": "播放", "ButtonPlaying": "正在播放", "ButtonPlaylists": "播放列表", + "ButtonPrevious": "上一個", "ButtonPreviousChapter": "過去的章節", "ButtonPurgeAllCache": "清理所有快取", "ButtonPurgeItemsCache": "清理項目快取", @@ -76,7 +79,7 @@ "ButtonSaveTracklist": "保存音軌列表", "ButtonScan": "掃描", "ButtonScanLibrary": "掃描庫", - "ButtonSearch": "查找", + "ButtonSearch": "搜索", "ButtonSelectFolderPath": "選擇資料夾路徑", "ButtonSeries": "系列", "ButtonSetChaptersFromTracks": "將音軌設定為章節", @@ -97,7 +100,7 @@ "ErrorUploadFetchMetadataAPI": "獲取元數據時出錯", "ErrorUploadFetchMetadataNoResults": "無法獲取元數據 - 嘗試更新標題和/或作者", "ErrorUploadLacksTitle": "必須有標題", - "HeaderAccount": "帳號", + "HeaderAccount": "賬號", "HeaderAdvanced": "高級", "HeaderAppriseNotificationSettings": "測試通知設定", "HeaderAudioTracks": "音軌", @@ -111,6 +114,7 @@ "HeaderCollectionItems": "收藏項目", "HeaderCover": "封面", "HeaderCurrentDownloads": "當前下載", + "HeaderCustomMessageOnLogin": "登錄時的自定義信息", "HeaderCustomMetadataProviders": "自訂 Metadata 提供者", "HeaderDetails": "詳情", "HeaderDownloadQueue": "下載佇列", @@ -144,7 +148,7 @@ "HeaderNewLibrary": "新建媒體庫", "HeaderNotifications": "通知", "HeaderOpenIDConnectAuthentication": "OpenID 連接身份驗證", - "HeaderOpenRSSFeed": "打開 RSS 源", + "HeaderOpenRSSFeed": "打開 Rss 源", "HeaderOtherFiles": "其他檔案", "HeaderPasswordAuthentication": "密碼認證", "HeaderPermissions": "權限", @@ -168,7 +172,7 @@ "HeaderSettingsExperimental": "實驗功能", "HeaderSettingsGeneral": "通用", "HeaderSettingsScanner": "掃描", - "HeaderSleepTimer": "睡眠計時", + "HeaderSleepTimer": "睡眠定時", "HeaderStatsLargestItems": "最大的項目", "HeaderStatsLongestItems": "項目時長(小時)", "HeaderStatsMinutesListeningChart": "收聽分鐘數(最近7天)", @@ -182,8 +186,12 @@ "HeaderUpdateDetails": "更新詳情", "HeaderUpdateLibrary": "更新媒體庫", "HeaderUsers": "使用者", + "HeaderYearReview": "{0} 年回顧", "HeaderYourStats": "你的統計數據", "LabelAbridged": "概要", + "LabelAbridgedChecked": "刪節版(已勾選)", + "LabelAbridgedUnchecked": "未刪節版(未勾選)", + "LabelAccessibleBy": "可訪問", "LabelAccountType": "帳號類型", "LabelAccountTypeAdmin": "管理員", "LabelAccountTypeGuest": "來賓", @@ -260,26 +268,32 @@ "LabelDownload": "下載", "LabelDownloadNEpisodes": "下載 {0} 集", "LabelDuration": "持續時間", + "LabelDurationComparisonExactMatch": "(完全匹配)", + "LabelDurationComparisonLonger": "({0} 更長)", + "LabelDurationComparisonShorter": "({0} 更短)", "LabelDurationFound": "找到持續時間:", "LabelEbook": "電子書", "LabelEbooks": "電子書", "LabelEdit": "編輯", "LabelEmail": "郵箱", "LabelEmailSettingsFromAddress": "發件人位址", + "LabelEmailSettingsRejectUnauthorized": "拒絕未經授權的證書", + "LabelEmailSettingsRejectUnauthorizedHelp": "停用 SSL 證書驗證可能會使您的連接暴露於安全風險中,例如中間人攻擊。僅在您了解其含義並信任您所連接的郵件伺服器的情況下才停用此選項。", "LabelEmailSettingsSecure": "安全", "LabelEmailSettingsSecureHelp": "如果選是, 則連接將在連接到伺服器時使用TLS. 如果選否, 則若伺服器支援STARTTLS擴展, 則使用TLS. 在大多數情況下, 如果連接到465埠, 請將該值設定為是. 對於587或25埠, 請保持為否. (來自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "測試位址", "LabelEmbeddedCover": "嵌入封面", "LabelEnable": "啟用", "LabelEnd": "結束", + "LabelEndOfChapter": "章節結束", "LabelEpisode": "劇集", "LabelEpisodeTitle": "劇集標題", "LabelEpisodeType": "劇集類型", "LabelExample": "示例", "LabelExplicit": "信息準確", - "LabelFeedURL": "源 URL", + "LabelFeedURL": "源鏈接", "LabelFetchingMetadata": "正在獲取元數據", - "LabelFile": "檔案", + "LabelFile": "文件", "LabelFileBirthtime": "檔案創建時間", "LabelFileModified": "檔案修改時間", "LabelFilename": "檔名", @@ -288,6 +302,7 @@ "LabelFinished": "已聽完", "LabelFolder": "資料夾", "LabelFolders": "資料夾", + "LabelFontBoldness": "字體粗細", "LabelFontFamily": "字體系列", "LabelFontItalic": "斜體", "LabelFontScale": "字體比例", @@ -353,7 +368,7 @@ "LabelMobileRedirectURIs": "允許移動應用重定向 URI", "LabelMobileRedirectURIsDescription": "這是移動應用程序的有效重定向 URI 白名單. 預設值為 audiobookshelf://oauth,您可以刪除它或加入其他 URI 以進行第三方應用集成. 使用星號 (*) 作為唯一條目允許任何 URI.", "LabelMore": "更多", - "LabelMoreInfo": "更多..", + "LabelMoreInfo": "更多信息", "LabelName": "名稱", "LabelNarrator": "講述者", "LabelNarrators": "講述者", @@ -399,7 +414,7 @@ "LabelPodcasts": "播客", "LabelPort": "埠", "LabelPrefixesToIgnore": "忽略的前綴 (不區分大小寫)", - "LabelPreventIndexing": "防止 iTunes 和 Google 播客目錄對你的源進行索引", + "LabelPreventIndexing": "防止您的訂閱源被 iTunes 和 Google 播客目錄索引", "LabelPrimaryEbook": "主電子書", "LabelProgress": "進度", "LabelProvider": "供應商", @@ -412,6 +427,7 @@ "LabelRSSFeedPreventIndexing": "防止索引", "LabelRSSFeedSlug": "RSS 源段", "LabelRSSFeedURL": "RSS 源 URL", + "LabelRandomly": "隨機", "LabelRead": "閱讀", "LabelReadAgain": "再次閱讀", "LabelReadEbookWithoutProgress": "閱讀電子書而不保存進度", @@ -635,20 +651,20 @@ "MessageNoFoldersAvailable": "沒有可用資料夾", "MessageNoGenres": "無流派", "MessageNoIssues": "無問題", - "MessageNoItems": "無項目", - "MessageNoItemsFound": "未找到任何項目", - "MessageNoListeningSessions": "無收聽會話", + "MessageNoItems": "沒有項目", + "MessageNoItemsFound": "沒有找到任何項目", + "MessageNoListeningSessions": "沒有收聽會話", "MessageNoLogs": "無日誌", "MessageNoMediaProgress": "無媒體進度", "MessageNoNotifications": "無通知", - "MessageNoPodcastsFound": "未找到播客", + "MessageNoPodcastsFound": "沒有找到播客", "MessageNoResults": "無結果", "MessageNoSearchResultsFor": "沒有搜尋到結果 \"{0}\"", "MessageNoSeries": "無系列", "MessageNoTags": "無標籤", "MessageNoTasksRunning": "沒有正在運行的任務", "MessageNoUpdatesWereNecessary": "無需更新", - "MessageNoUserPlaylists": "你沒有播放列表", + "MessageNoUserPlaylists": "您沒有播放列表", "MessageNotYetImplemented": "尚未實施", "MessageOr": "或", "MessagePauseChapter": "暫停章節播放", @@ -660,7 +676,7 @@ "MessageRemoveEpisodes": "移除 {0} 劇集", "MessageRemoveFromPlayerQueue": "從播放佇列中移除", "MessageRemoveUserWarning": "是否確實要永久刪除使用者 \"{0}\"?", - "MessageReportBugsAndContribute": "報告錯誤、請求功能和貢獻在", + "MessageReportBugsAndContribute": "報告錯誤、請求功能和做出貢獻", "MessageResetChaptersConfirm": "你確定要重置章節並撤消你所做的更改嗎?", "MessageRestoreBackupConfirm": "你確定要恢復創建的這個備份", "MessageRestoreBackupWarning": "恢復備份將覆蓋位於 /config 的整個資料庫並覆蓋 /metadata/items & /metadata/authors 中的圖像.

    備份不會修改媒體庫資料夾中的任何檔案. 如果您已啟用伺服器設定將封面和元數據存儲在庫資料夾中,則不會備份或覆蓋這些內容.

    將自動刷新使用伺服器的所有客戶端.", @@ -681,8 +697,8 @@ "NoteChangeRootPassword": "Root 是唯一可以擁有空密碼的使用者", "NoteChapterEditorTimes": "注意: 第一章開始時間必須保持在 0:00, 最後一章開始時間不能超過有聲書持續時間.", "NoteFolderPicker": "注意: 將不顯示已映射的資料夾", - "NoteRSSFeedPodcastAppsHttps": "警告: 大多數播客應用程序都需要 RSS 源 URL 使用 HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "警告: 您的一集或多集沒有發布日期. 一些播客應用程序要求這樣做.", + "NoteRSSFeedPodcastAppsHttps": "警告:大多數播客應用程式要求 RSS 訂閱源 URL 使用 HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "警告:您的一個或多個劇集沒有發布日期。某些播客應用程式要求提供此資訊。", "NoteUploaderFoldersWithMediaFiles": "包含媒體檔案的資料夾將作為單獨的媒體庫項目處理.", "NoteUploaderOnlyAudioFiles": "如果只上傳音頻檔, 則每個音頻檔將作為單獨的有聲書處理.", "NoteUploaderUnsupportedFiles": "不支援的檔案將被忽略. 選擇或刪除資料夾時, 將忽略不在項目資料夾中的其他檔案.", @@ -705,7 +721,7 @@ "ToastBackupUploadSuccess": "備份已上傳", "ToastBatchUpdateFailed": "批量更新失敗", "ToastBatchUpdateSuccess": "批量更新成功", - "ToastBookmarkCreateFailed": "創建書籤失敗", + "ToastBookmarkCreateFailed": "創建書簽失敗", "ToastBookmarkCreateSuccess": "書籤已新增", "ToastBookmarkRemoveSuccess": "書籤已刪除", "ToastBookmarkUpdateSuccess": "書籤已更新", From 92d083164f0a15728578dd97152444cd1458e31e Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 11 Nov 2024 18:35:36 +0000 Subject: [PATCH 371/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index e3320ea877..0e04164dfe 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -136,7 +136,7 @@ "HeaderEmailSettings": "Nastavitve e-pošte", "HeaderEpisodes": "Epizode", "HeaderEreaderDevices": "E-bralniki", - "HeaderEreaderSettings": "Nastavitve ebralnika", + "HeaderEreaderSettings": "Nastavitve e-bralnika", "HeaderFiles": "Datoteke", "HeaderFindChapters": "Najdi poglavja", "HeaderIgnoredFiles": "Prezrte datoteke", @@ -366,7 +366,7 @@ "LabelHardDeleteFile": "Trdo brisanje datoteke", "LabelHasEbook": "Ima e-knjigo", "LabelHasSupplementaryEbook": "Ima dodatno e-knjigo", - "LabelHideSubtitles": "Skrij podnapise", + "LabelHideSubtitles": "Skrij podnaslove", "LabelHighestPriority": "Najvišja prioriteta", "LabelHost": "Gostitelj", "LabelHour": "Ura", @@ -568,8 +568,8 @@ "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medijski element kot končan, ko", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči prejšnje knjige v nadaljevanju serije", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.", - "LabelSettingsParseSubtitles": "Uporabi podnapise", - "LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.
    Podnapis mora biti ločen z \" - \"
    npr. \"Naslov knjige – tu podnapis\" ima podnapis \"tu podnapis\"", + "LabelSettingsParseSubtitles": "Razčleni podnaslove", + "LabelSettingsParseSubtitlesHelp": "Izvleci padnaslove iz imen map zvočnih knjig.
    Podnaslov mora biti ločen z \" - \"
    npr. \"Naslov knjige – tu podnaslove\" ima podnaslov \"tu podnaslov\"", "LabelSettingsPreferMatchedMetadata": "Prednost imajo ujemajoči se metapodatki", "LabelSettingsPreferMatchedMetadataHelp": "Pri uporabi hitrega ujemanja bodo ujemajoči se podatki preglasili podrobnosti artikla. Hitro ujemanje bo privzeto izpolnil samo manjkajoče podrobnosti.", "LabelSettingsSkipMatchingBooksWithASIN": "Preskoči ujemajoče se knjige, ki že imajo ASIN", @@ -588,7 +588,7 @@ "LabelShareURL": "Deli URL", "LabelShowAll": "Prikaži vse", "LabelShowSeconds": "Prikaži sekunde", - "LabelShowSubtitles": "Prikaži podnapise", + "LabelShowSubtitles": "Prikaži podnaslove", "LabelSize": "Velikost", "LabelSleepTimer": "Časovnik za spanje", "LabelSlug": "Slug", @@ -611,7 +611,7 @@ "LabelStatsOverallDays": "Skupaj dnevi", "LabelStatsOverallHours": "Skupaj ur", "LabelStatsWeekListening": "Tednov poslušanja", - "LabelSubtitle": "Podnapis", + "LabelSubtitle": "Podnaslov", "LabelSupportedFileTypes": "Podprte vrste datotek", "LabelTag": "Oznaka", "LabelTags": "Oznake", From f941ea650032ecfc5a7dfcc7dbb06d329f5e2676 Mon Sep 17 00:00:00 2001 From: burghy86 Date: Tue, 12 Nov 2024 19:43:54 +0000 Subject: [PATCH 372/539] Translated using Weblate (Italian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/client/strings/it.json b/client/strings/it.json index 1450b972c3..42e83fe061 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Aggiornamento della notifica", "HeaderNotifications": "Notifiche", "HeaderOpenIDConnectAuthentication": "Autenticazione OpenID Connect", + "HeaderOpenListeningSessions": "Apri sessioni di ascolto", "HeaderOpenRSSFeed": "Apri il flusso RSS", "HeaderOtherFiles": "Altri File", "HeaderPasswordAuthentication": "Autenticazione della password", @@ -180,6 +181,7 @@ "HeaderRemoveEpisodes": "Rimuovi {0} Episodi", "HeaderSavedMediaProgress": "Progressi salvati", "HeaderSchedule": "Schedula", + "HeaderScheduleEpisodeDownloads": "Imposta il download automatico degli episodi", "HeaderScheduleLibraryScans": "Schedula la scansione della libreria", "HeaderSession": "Sessione", "HeaderSetBackupSchedule": "Imposta programmazione Backup", @@ -225,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Tutti gli Utenti Esclusi gli ospiti", "LabelAllUsersIncludingGuests": "Tutti gli Utenti Inclusi gli ospiti", "LabelAlreadyInYourLibrary": "Già esistente nella libreria", + "LabelApiToken": "API Token", "LabelAppend": "Appese", "LabelAudioBitrate": "Audio Bitrate (es. 128k)", "LabelAudioChannels": "Canali Audio (1 o 2)", @@ -250,15 +253,18 @@ "LabelBackupsNumberToKeep": "Numero di backup da mantenere", "LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.", "LabelBitrate": "Velocità di trasmissione", + "LabelBonus": "Bonus", "LabelBooks": "Libri", "LabelButtonText": "Buttone Testo", "LabelByAuthor": "da {0}", "LabelChangePassword": "Cambia Password", "LabelChannels": "Canali", + "LabelChapterCount": "{0} Capitoli", "LabelChapterTitle": "Titoli dei Capitoli", "LabelChapters": "Capitoli", "LabelChaptersFound": "Capitoli Trovati", "LabelClickForMoreInfo": "Click per altre Info", + "LabelClickToUseCurrentValue": "Clicca per usare il valore corrente", "LabelClosePlayer": "Chiudi player", "LabelCodec": "Codec", "LabelCollapseSeries": "Comprimi Serie", @@ -320,9 +326,13 @@ "LabelEnd": "Fine", "LabelEndOfChapter": "Fine Capitolo", "LabelEpisode": "Episodio", + "LabelEpisodeNotLinkedToRssFeed": "Episode non linkati nel RSS feed", + "LabelEpisodeNumber": "Episodio #{0}", "LabelEpisodeTitle": "Titolo Episodio", "LabelEpisodeType": "Tipo Episodio", + "LabelEpisodeUrlFromRssFeed": "URL dell'episodio dal RSS feed", "LabelEpisodes": "Episodi", + "LabelEpisodic": "Episodico", "LabelExample": "Esempio", "LabelExpandSeries": "Espandi Serie", "LabelExpandSubSeries": "Espandi Sub Serie", @@ -350,6 +360,7 @@ "LabelFontScale": "Dimensione font", "LabelFontStrikethrough": "Barrato", "LabelFormat": "Formato", + "LabelFull": "Pieno", "LabelGenre": "Genere", "LabelGenres": "Generi", "LabelHardDeleteFile": "Elimina Definitivamente", @@ -405,6 +416,10 @@ "LabelLowestPriority": "Priorità Minima", "LabelMatchExistingUsersBy": "Abbina gli utenti esistenti per", "LabelMatchExistingUsersByDescription": "Utilizzato per connettere gli utenti esistenti. Una volta connessi, gli utenti verranno abbinati a un ID univoco dal tuo provider SSO", + "LabelMaxEpisodesToDownload": "Max # di episodi da scaricare. Usa 0 per illimitati.", + "LabelMaxEpisodesToDownloadPerCheck": "Massimo # di nuovi episodi da scaricare per il controllo", + "LabelMaxEpisodesToKeep": "Massimo # di episodi da tenere", + "LabelMaxEpisodesToKeepHelp": "Il valore 0 non imposta alcun limite massimo. Dopo che un nuovo episodio è stato scaricato automaticamente, questo eliminerà l'episodio più vecchio se hai più di X episodi. Questo eliminerà solo 1 episodio per ogni nuovo download.", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", "LabelMetaTag": "Meta Tag", @@ -450,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come gruppo. se configurato, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.", "LabelOpenRSSFeed": "Apri RSS Feed", "LabelOverwrite": "Sovrascrivi", + "LabelPaginationPageXOfY": "Pagina {0} di {1}", "LabelPassword": "Password", "LabelPath": "Percorso", "LabelPermanent": "Permanente", "LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie", "LabelPermissionsAccessAllTags": "Può accedere a tutti i tag", "LabelPermissionsAccessExplicitContent": "Può accedere a contenuti espliciti", + "LabelPermissionsCreateEreader": "Può creare un e-reader", "LabelPermissionsDelete": "Può Cancellare", "LabelPermissionsDownload": "Può Scaricare", "LabelPermissionsUpdate": "Può Aggiornare", @@ -500,18 +517,24 @@ "LabelRedo": "Rifai", "LabelRegion": "Regione", "LabelReleaseDate": "Data Release", + "LabelRemoveAllMetadataAbs": "Remuovi tutti i metadata.abs files", + "LabelRemoveAllMetadataJson": "Rimuovi tutti i metadata.json files", "LabelRemoveCover": "Rimuovi cover", + "LabelRemoveMetadataFile": "Rimuovi i file metadata nella cartella della libreria", + "LabelRemoveMetadataFileHelp": "Rimuovi tutti i file metadata.json e i file metadata.abs nelle tue {0} cartelle.", "LabelRowsPerPage": "Righe per pagina", "LabelSearchTerm": "Ricerca", "LabelSearchTitle": "Cerca Titolo", "LabelSearchTitleOrASIN": "Cerca titolo o ASIN", "LabelSeason": "Stagione", + "LabelSeasonNumber": "Stagione #{0}", "LabelSelectAll": "Seleziona tutto", "LabelSelectAllEpisodes": "Seleziona tutti gli Episodi", "LabelSelectEpisodesShowing": "Selezionati {0} episodi da visualizzare", "LabelSelectUsers": "Selezione Utenti", "LabelSendEbookToDevice": "Invia il libro a...", "LabelSequence": "Sequenza", + "LabelSerial": "Seriale", "LabelSeries": "Serie", "LabelSeriesName": "Nome Serie", "LabelSeriesProgress": "Cominciato", @@ -540,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Le serie che hanno un solo libro saranno nascoste dalla pagina della serie e dagli scaffali della home page.", "LabelSettingsHomePageBookshelfView": "Home page con sfondo legno", "LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "La percentuale di completamento è maggiore di", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Il tempo rimanente è inferiore a (secondi)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Contrassegna l'elemento multimediale come terminato quando", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Salta i libri precedenti nella serie Continua", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Lo scaffale della home page Continua serie mostra il primo libro non iniziato della serie che ha almeno un libro finito e nessun libro in corso. Abilitando questa impostazione le serie continueranno dal libro completato più lontano invece che dal primo libro non iniziato.", "LabelSettingsParseSubtitles": "Analizza sottotitoli", @@ -604,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} minuti", "LabelTimeDurationXSeconds": "{0} secondi", "LabelTimeInMinutes": "Tempo in minuti", + "LabelTimeLeft": "{0} sinistra", "LabelTimeListened": "Tempo di Ascolto", "LabelTimeListenedToday": "Tempo di Ascolto Oggi", "LabelTimeRemaining": "{0} rimanente", @@ -624,6 +651,7 @@ "LabelTracksMultiTrack": "Multi-traccia", "LabelTracksNone": "Nessuna traccia", "LabelTracksSingleTrack": "Traccia-singola", + "LabelTrailer": "Trailer", "LabelType": "Tipo", "LabelUnabridged": "Integrale", "LabelUndo": "Annulla", @@ -640,6 +668,7 @@ "LabelUseAdvancedOptions": "Usa le opzioni avanzate", "LabelUseChapterTrack": "Usa il Capitolo della Traccia", "LabelUseFullTrack": "Usa la traccia totale", + "LabelUseZeroForUnlimited": "Usa 0 per illimitato", "LabelUser": "Utente", "LabelUsername": "Nome utente", "LabelValue": "Valore", @@ -698,6 +727,7 @@ "MessageConfirmPurgeCache": "L'eliminazione della cache eliminerà l'intera directory dei /metadata/cache.

    Sei sicuro di voler rimuovere la directory della cache?", "MessageConfirmPurgeItemsCache": "L'eliminazione della cache degli elementi eliminerà l'intera directory /metadata/cache/oggetti.
    Sei sicuro?", "MessageConfirmQuickEmbed": "Attenzione! L'incorporamento rapido non eseguirà il backup dei file audio. Assicurati di avere un backup dei tuoi file audio.

    Vuoi Continuare?", + "MessageConfirmQuickMatchEpisodes": "Gli episodi di corrispondenza rapida sovrascriveranno i dettagli se viene trovata una corrispondenza. Saranno aggiornati solo gli episodi non corrispondenti. Sei sicuro?", "MessageConfirmReScanLibraryItems": "Sei sicuro di voler ripetere la scansione? {0} oggetti?", "MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?", "MessageConfirmRemoveAuthor": "Sei sicuro di voler rimuovere l'autore? \"{0}\"?", @@ -705,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", "MessageConfirmRemoveListeningSessions": "Sei sicuro di voler rimuovere {0} sessioni di Ascolto?", + "MessageConfirmRemoveMetadataFiles": "Vuoi davvero rimuovere tutti i metadati.{0} file nelle cartelle degli elementi della tua libreria?", "MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?", "MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?", "MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?", @@ -785,6 +816,7 @@ "MessagePodcastSearchField": "Inserisci il termine di ricerca o l'URL del feed RSS", "MessageQuickEmbedInProgress": "Incorporamento rapido in corso", "MessageQuickEmbedQueue": "In coda per incorporamento rapido ({0} in coda)", + "MessageQuickMatchAllEpisodes": "Associamento veloce di Tutti gli episodi", "MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".", "MessageRemoveChapter": "Rimuovi Capitolo", "MessageRemoveEpisodes": "rimuovi {0} episodio(i)", @@ -883,6 +915,7 @@ "StatsYearInReview": "ANNO IN RASSEGNA", "ToastAccountUpdateSuccess": "Account Aggiornato", "ToastAppriseUrlRequired": "È necessario immettere un indirizzo Apprise", + "ToastAsinRequired": "L'ASIN è obbligatorio", "ToastAuthorImageRemoveSuccess": "Immagine Autore Rimossa", "ToastAuthorNotFound": "Autore\"{0}\" non trovato", "ToastAuthorRemoveSuccess": "Autore rimosso", @@ -902,6 +935,8 @@ "ToastBackupUploadSuccess": "Backup caricato", "ToastBatchDeleteFailed": "Eliminazione batch non riuscita", "ToastBatchDeleteSuccess": "Eliminazione batch riuscita", + "ToastBatchQuickMatchFailed": "Batch Quick Match non riuscito!", + "ToastBatchQuickMatchStarted": "Avviata la ricerca rapida in batch di {0} libri!", "ToastBatchUpdateFailed": "Batch di aggiornamento fallito", "ToastBatchUpdateSuccess": "Batch di aggiornamento finito", "ToastBookmarkCreateFailed": "Creazione segnalibro fallita", @@ -913,6 +948,7 @@ "ToastChaptersHaveErrors": "I capitoli contengono errori", "ToastChaptersMustHaveTitles": "I capitoli devono avere titoli", "ToastChaptersRemoved": "Capitoli rimossi", + "ToastChaptersUpdated": "Capitoli aggiornati", "ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito", "ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito", "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta", @@ -930,11 +966,14 @@ "ToastEncodeCancelSucces": "Codifica annullata", "ToastEpisodeDownloadQueueClearFailed": "Impossibile cancellare la coda", "ToastEpisodeDownloadQueueClearSuccess": "Coda di download degli episodi cancellata", + "ToastEpisodeUpdateSuccess": "{0} episodi aggiornati", "ToastErrorCannotShare": "Impossibile condividere in modo nativo su questo dispositivo", "ToastFailedToLoadData": "Impossibile caricare i dati", + "ToastFailedToMatch": "Impossibile abbinare", "ToastFailedToShare": "Impossibile condividere", "ToastFailedToUpdate": "Non aggiornato", "ToastInvalidImageUrl": "URL dell'immagine non valido", + "ToastInvalidMaxEpisodesToDownload": "Numero massimo di episodi non valido da scaricare", "ToastInvalidUrl": "URL non valido", "ToastItemCoverUpdateSuccess": "Cover aggiornata", "ToastItemDeletedFailed": "Impossibile eliminare l'elemento", @@ -953,14 +992,21 @@ "ToastLibraryScanStarted": "Scansione Libreria iniziata", "ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata", "ToastMatchAllAuthorsFailed": "Tutti gli autori non sono potuti essere classificati", + "ToastMetadataFilesRemovedError": "Errore durante la rimozione dei metadati. {0} file", + "ToastMetadataFilesRemovedNoneFound": "Nessun metadato. {0} file trovati nella libreria", + "ToastMetadataFilesRemovedNoneRemoved": "Nessun metadato. {0} file rimossi", + "ToastMetadataFilesRemovedSuccess": "{0} metadati.{1} file rimossi", + "ToastMustHaveAtLeastOnePath": "Deve avere almeno un percorso", "ToastNameEmailRequired": "Nome ed email sono obbligatori", "ToastNameRequired": "Il nome è obbligatorio", + "ToastNewEpisodesFound": "{0} nuovi episodi trovati", "ToastNewUserCreatedFailed": "Impossibile creare l'account: \"{0}\"", "ToastNewUserCreatedSuccess": "Nuovo account creato", "ToastNewUserLibraryError": "È necessario selezionare almeno una libreria", "ToastNewUserPasswordError": "Deve avere una password, solo l'utente root può avere una password vuota", "ToastNewUserTagError": "Devi selezionare almeno un tag", "ToastNewUserUsernameError": "Inserisci un nome utente", + "ToastNoNewEpisodesFound": "Nessun nuovo episodio trovato", "ToastNoUpdatesNecessary": "Nessun aggiornamento necessario", "ToastNotificationCreateFailed": "Impossibile creare la notifica", "ToastNotificationDeleteFailed": "Impossibile eliminare la notifica", @@ -979,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "Impossibile ottenere il feed del podcast", "ToastPodcastNoEpisodesInFeed": "Nessun episodio trovato nel feed RSS", "ToastPodcastNoRssFeed": "Il podcast non ha un feed RSS", + "ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione", "ToastProviderCreatedFailed": "Impossibile aggiungere il provider", "ToastProviderCreatedSuccess": "Aggiunto nuovo provider", "ToastProviderNameAndUrlRequired": "Nome e URL richiesti", @@ -1005,6 +1052,7 @@ "ToastSessionCloseFailed": "Disconnessione Fallita", "ToastSessionDeleteFailed": "Errore eliminazione sessione", "ToastSessionDeleteSuccess": "Sessione cancellata", + "ToastSleepTimerDone": "Timer di spegnimento eseguito... zZzzZz", "ToastSlugMustChange": "Lo slug contiene caratteri non validi", "ToastSlugRequired": "È richiesto lo slug", "ToastSocketConnected": "Socket connesso", From 997afc1b2f6bd0055f75e47c16db95d119c2e617 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Tue, 12 Nov 2024 06:13:00 +0000 Subject: [PATCH 373/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 0e04164dfe..bbcf8055a2 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -407,7 +407,7 @@ "LabelLibraryItem": "Element knjižnice", "LabelLibraryName": "Ime knjižnice", "LabelLimit": "Omejitev", - "LabelLineSpacing": "Razmik med vrsticami", + "LabelLineSpacing": "Vrstični razmak", "LabelListenAgain": "Poslušaj znova", "LabelLogLevelDebug": "Odpravljanje napak", "LabelLogLevelInfo": "Info", From 45f8b06d569df68a6cf7b14ae4db72e1fff34ac9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 15 Nov 2024 08:30:54 -0600 Subject: [PATCH 374/539] Fix:CBC Radio podcast RSS feeds not accepting our user-agent string #3322 --- server/utils/podcastUtils.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 92679903ba..ac96c8d049 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -228,6 +228,13 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) + let userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS)' + // Workaround for CBC RSS feeds rejecting our user agent string + // See: https://github.com/advplyr/audiobookshelf/issues/3322 + if (feedUrl.startsWith('https://www.cbc.ca')) { + userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS) - CBC' + } + return axios({ url: feedUrl, method: 'GET', @@ -235,7 +242,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8', - 'User-Agent': 'audiobookshelf (+https://audiobookshelf.org; like iTMS)' + 'User-Agent': userAgent }, httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl), httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl) From 5ccf5d7150bb94319987070ba10652b93cdbcab2 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 16 Nov 2024 06:26:32 +0200 Subject: [PATCH 375/539] Use a simpler database fetch in fullUpdateFromOld --- server/models/LibraryItem.js | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 17c3b12586..e867a96a8b 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -237,35 +237,7 @@ class LibraryItem extends Model { * @returns {Promise} true if updates were made */ static async fullUpdateFromOld(oldLibraryItem) { - const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, { - include: [ - { - model: this.sequelize.models.book, - include: [ - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['id', 'sequence'] - } - } - ] - }, - { - model: this.sequelize.models.podcast, - include: [ - { - model: this.sequelize.models.podcastEpisode - } - ] - } - ] - }) + const libraryItemExpanded = await this.getExpandedById(oldLibraryItem.id) if (!libraryItemExpanded) return false let hasUpdates = false From d5fbc1d45592414a5684a89bc40940a42020a020 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 17 Nov 2024 12:22:15 -0700 Subject: [PATCH 376/539] Add: statement about workflows passing --- .github/pull_request_template.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f41e46cce7..0cd521a5b5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,20 @@ ## Brief summary - + + +## Which issue is fixed? + + ## In-depth Description From 2b7e3f0efe6fae0d6138cb95ac72224f81b31bfc Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 15:45:21 -0600 Subject: [PATCH 377/539] Update uuid migration to v2.17.0 and for all tables still using UUIDv4 --- server/migrations/changelog.md | 12 +-- server/migrations/v2.16.3-uuid-replacement.js | 50 ---------- server/migrations/v2.17.0-uuid-replacement.js | 98 +++++++++++++++++++ server/models/Feed.js | 2 +- server/models/MediaItemShare.js | 2 +- server/models/MediaProgress.js | 2 +- server/models/PlaybackSession.js | 2 +- server/models/PlaylistMediaItem.js | 2 +- 8 files changed, 109 insertions(+), 61 deletions(-) delete mode 100644 server/migrations/v2.16.3-uuid-replacement.js create mode 100644 server/migrations/v2.17.0-uuid-replacement.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index bffd4682d6..8960ade2f7 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,9 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.16.3 | v2.16.3-uuid-replacement | Changes `mediaId` column in `libraryItem` table to match the primary key type of `books` and `podcasts` | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | diff --git a/server/migrations/v2.16.3-uuid-replacement.js b/server/migrations/v2.16.3-uuid-replacement.js deleted file mode 100644 index 66bf21ac98..0000000000 --- a/server/migrations/v2.16.3-uuid-replacement.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @typedef MigrationContext - * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. - * @property {import('../Logger')} logger - a Logger object. - * - * @typedef MigrationOptions - * @property {MigrationContext} context - an object containing the migration context. - */ - -/** - * This upward migration script changes the `mediaId` column in the `libraryItems` table to be a UUID and match other tables. - * - * @param {MigrationOptions} options - an object containing the migration context. - * @returns {Promise} - A promise that resolves when the migration is complete. - */ -async function up({ context: { queryInterface, logger } }) { - // Upwards migration script - logger.info('[2.16.3 migration] UPGRADE BEGIN: 2.16.3-uuid-replacement') - - // Change mediaId column to using the query interface - logger.info('[2.16.3 migration] Changing mediaId column to UUID') - await queryInterface.changeColumn('libraryItems', 'mediaId', { - type: 'UUID' - }) - - // Completed migration - logger.info('[2.16.3 migration] UPGRADE END: 2.16.3-uuid-replacement') -} - -/** - * This downward migration script changes the `mediaId` column in the `libraryItems` table to be a UUIDV4 again. - * - * @param {MigrationOptions} options - an object containing the migration context. - * @returns {Promise} - A promise that resolves when the migration is complete. - */ -async function down({ context: { queryInterface, logger } }) { - // Downward migration script - logger.info('[2.16.3 migration] DOWNGRADE BEGIN: 2.16.3-uuid-replacement') - - // Change mediaId column to using the query interface - logger.info('[2.16.3 migration] Changing mediaId column to UUIDV4') - await queryInterface.changeColumn('libraryItems', 'mediaId', { - type: 'UUIDV4' - }) - - // Completed migration - logger.info('[2.16.3 migration] DOWNGRADE END: 2.16.3-uuid-replacement') -} - -module.exports = { up, down } diff --git a/server/migrations/v2.17.0-uuid-replacement.js b/server/migrations/v2.17.0-uuid-replacement.js new file mode 100644 index 0000000000..6460b79526 --- /dev/null +++ b/server/migrations/v2.17.0-uuid-replacement.js @@ -0,0 +1,98 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes table columns with data type UUIDv4 to UUID to match associated models. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.0 migration] UPGRADE BEGIN: 2.17.0-uuid-replacement') + + logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUID') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing feeds.entityId column to UUID') + await queryInterface.changeColumn('feeds', 'entityId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID') + await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUID') + await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', { + type: 'UUID' + }) + + // Completed migration + logger.info('[2.17.0 migration] UPGRADE END: 2.17.0-uuid-replacement') +} + +/** + * This downward migration script changes table columns data type back to UUIDv4. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.0 migration] DOWNGRADE BEGIN: 2.17.0-uuid-replacement') + + logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUIDV4') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing feeds.entityId column to UUIDV4') + await queryInterface.changeColumn('feeds', 'entityId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', { + type: 'UUIDV4' + }) + + // Completed migration + logger.info('[2.17.0 migration] DOWNGRADE END: 2.17.0-uuid-replacement') +} + +module.exports = { up, down } diff --git a/server/models/Feed.js b/server/models/Feed.js index 72321da926..4f51e66d9e 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -274,7 +274,7 @@ class Feed extends Model { }, slug: DataTypes.STRING, entityType: DataTypes.STRING, - entityId: DataTypes.UUIDV4, + entityId: DataTypes.UUID, entityUpdatedAt: DataTypes.DATE, serverAddress: DataTypes.STRING, feedURL: DataTypes.STRING, diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js index ffdc3ddd1f..38b8dbbf4b 100644 --- a/server/models/MediaItemShare.js +++ b/server/models/MediaItemShare.js @@ -109,7 +109,7 @@ class MediaItemShare extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, slug: DataTypes.STRING, pash: DataTypes.STRING, diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index d6a527f742..80204ef5cc 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -93,7 +93,7 @@ class MediaProgress extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, duration: DataTypes.FLOAT, currentTime: DataTypes.FLOAT, diff --git a/server/models/PlaybackSession.js b/server/models/PlaybackSession.js index c7c6323af6..196fbda6ce 100644 --- a/server/models/PlaybackSession.js +++ b/server/models/PlaybackSession.js @@ -179,7 +179,7 @@ class PlaybackSession extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, displayTitle: DataTypes.STRING, displayAuthor: DataTypes.STRING, diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index 25e7b8c553..1c53bea115 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -45,7 +45,7 @@ class PlaylistMediaItem extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, order: DataTypes.INTEGER }, From 75eef8d722f0f84c0ebbc5a5d714baf3602baf56 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 16:00:44 -0600 Subject: [PATCH 378/539] Fix:Book library sort by publishedYear #3620 - Updated sort to cast publishedYear to INTEGER --- server/utils/queries/libraryItemsBookFilters.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index e8b424ed97..b2784f5ddd 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -259,7 +259,7 @@ module.exports = { } else if (sortBy === 'media.duration') { return [['duration', dir]] } else if (sortBy === 'media.metadata.publishedYear') { - return [['publishedYear', dir]] + return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]] } else if (sortBy === 'media.metadata.authorNameLF') { return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]] } else if (sortBy === 'media.metadata.authorName') { From 9940f1d6dbd12773b2a41059b81dc12228ea8457 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 15 Nov 2024 08:28:00 +0000 Subject: [PATCH 379/539] Translated using Weblate (Spanish) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index d9e24723c7..06aa1b8b52 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -71,8 +71,8 @@ "ButtonQuickMatch": "Encontrar Rápido", "ButtonReScan": "Re-Escanear", "ButtonRead": "Leer", - "ButtonReadLess": "Lea menos", - "ButtonReadMore": "Lea mas", + "ButtonReadLess": "Leer menos", + "ButtonReadMore": "Leer más", "ButtonRefresh": "Refrecar", "ButtonRemove": "Remover", "ButtonRemoveAll": "Remover Todos", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "Añadido a la lista de reproducción", "LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción", "LabelAddedAt": "Añadido", - "LabelAddedDate": "Añadido {0}", + "LabelAddedDate": "{0} Añadido", "LabelAdminUsersOnly": "Solamente usuarios administradores", "LabelAll": "Todos", "LabelAllUsers": "Todos los Usuarios", @@ -681,8 +681,8 @@ "LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelXBooks": "{0} libros", "LabelXItems": "{0} elementos", - "LabelYearReviewHide": "Ocultar Year in Review", - "LabelYearReviewShow": "Ver Year in Review", + "LabelYearReviewHide": "Ocultar Resumen del año", + "LabelYearReviewShow": "Resumen del año", "LabelYourAudiobookDuration": "Duración de tu Audiolibro", "LabelYourBookmarks": "Tus Marcadores", "LabelYourPlaylists": "Tus Listas", @@ -779,7 +779,7 @@ "MessageNoBackups": "Sin Respaldos", "MessageNoBookmarks": "Sin marcadores", "MessageNoChapters": "Sin capítulos", - "MessageNoCollections": "Sin Colecciones", + "MessageNoCollections": "Sin colecciones", "MessageNoCoversFound": "Ninguna Portada Encontrada", "MessageNoDescription": "Sin Descripción", "MessageNoDevices": "Sin dispositivos", From 26ef33a4b6188bda0a480e290c95f8c98b903000 Mon Sep 17 00:00:00 2001 From: biuklija Date: Thu, 14 Nov 2024 12:02:48 +0000 Subject: [PATCH 380/539] Translated using Weblate (Croatian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index d7d0fde54e..502973c441 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -813,7 +813,7 @@ "MessagePlaylistCreateFromCollection": "Stvori popis za izvođenje od zbirke", "MessagePleaseWait": "Molimo pričekajte...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nema adresu RSS izvora za prepoznavanje", - "MessagePodcastSearchField": "Unesite upit za pretragu ili URL RSS izvora", + "MessagePodcastSearchField": "Upišite izraz za pretraživanje ili URL RSS izvora", "MessageQuickEmbedInProgress": "Brzo ugrađivanje u tijeku", "MessageQuickEmbedQueue": "Dodano u red za brzo ugrađivanje ({0} u redu izvođenja)", "MessageQuickMatchAllEpisodes": "Brzo prepoznavanje svih nastavaka", From 3e6a2d670ece6433a8fe4c198d96d09b4d969c8c Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Thu, 14 Nov 2024 20:19:09 +0000 Subject: [PATCH 381/539] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 668ccd33e7..e4aecc90ad 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -813,7 +813,7 @@ "MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки", "MessagePleaseWait": "Будь ласка, зачекайте...", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку", - "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS фіду", + "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки", "MessageQuickEmbedInProgress": "Швидке вбудовування в процесі", "MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)", "MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів", From cf19dd23cf2d6dfcb5f7e1d8db6528694e3cfa45 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Fri, 15 Nov 2024 11:26:33 +0000 Subject: [PATCH 382/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 2830a7101a..072cbd39ef 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -71,7 +71,7 @@ "ButtonQuickMatch": "快速匹配", "ButtonReScan": "重新扫描", "ButtonRead": "读取", - "ButtonReadLess": "阅读更少", + "ButtonReadLess": "阅读较少", "ButtonReadMore": "阅读更多", "ButtonRefresh": "刷新", "ButtonRemove": "移除", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddedAt": "添加于", - "LabelAddedDate": "添加 {0}", + "LabelAddedDate": "已添加 {0}", "LabelAdminUsersOnly": "仅限管理员用户", "LabelAll": "全部", "LabelAllUsers": "所有用户", From b5f0a6f4a6f9c43fdbe06bc6a3aeb68b2c07aec2 Mon Sep 17 00:00:00 2001 From: DR Date: Sat, 16 Nov 2024 21:01:30 +0000 Subject: [PATCH 383/539] Translated using Weblate (Hebrew) Currently translated at 70.5% (756 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/he/ --- client/strings/he.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/he.json b/client/strings/he.json index 9f7822b9b1..23b9fb723d 100644 --- a/client/strings/he.json +++ b/client/strings/he.json @@ -8,10 +8,10 @@ "ButtonAddYourFirstLibrary": "הוסף את הספרייה הראשונה שלך", "ButtonApply": "החל", "ButtonApplyChapters": "החל פרקים", - "ButtonAuthors": "יוצרים", + "ButtonAuthors": "סופרים", "ButtonBack": "חזור", "ButtonBrowseForFolder": "עיין בתיקייה", - "ButtonCancel": "בטל", + "ButtonCancel": "ביטול", "ButtonCancelEncode": "בטל קידוד", "ButtonChangeRootPassword": "שנה סיסמת root", "ButtonCheckAndDownloadNewEpisodes": "בדוק והורד פרקים חדשים", From d25a21cd3241b5be585a70f7e973fb5dd44f90c3 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Dos Santos Garcia Date: Sat, 16 Nov 2024 09:59:01 +0000 Subject: [PATCH 384/539] Translated using Weblate (Portuguese (Brazil)) Currently translated at 72.6% (778 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/ --- client/strings/pt-br.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index 68fad736ef..7df7c47dec 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -258,12 +258,15 @@ "LabelDiscFromFilename": "Disco a partir do nome do arquivo", "LabelDiscFromMetadata": "Disco a partir dos metadados", "LabelDiscover": "Descobrir", + "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download de {0} Episódios", "LabelDuration": "Duração", "LabelDurationComparisonExactMatch": "(exato)", "LabelDurationComparisonLonger": "({0} maior)", "LabelDurationComparisonShorter": "({0} menor)", "LabelDurationFound": "Duração comprovada:", + "LabelEbook": "Ebook", + "LabelEbooks": "Ebooks", "LabelEdit": "Editar", "LabelEmailSettingsFromAddress": "Remetente", "LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados", From 4cfd18c81ac4a4b0859d112a9875f47ee4a49bf4 Mon Sep 17 00:00:00 2001 From: Mohamad Dahhan Date: Sat, 16 Nov 2024 00:46:34 +0000 Subject: [PATCH 385/539] Translated using Weblate (Arabic) Currently translated at 3.8% (41 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 44 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index 0967ef424b..673b0238f5 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -1 +1,43 @@ -{} +{ + "ButtonAdd": "إضافة", + "ButtonAddChapters": "إضافة الفصول", + "ButtonAddDevice": "إضافة جهاز", + "ButtonAddLibrary": "إضافة مكتبة", + "ButtonAddPodcasts": "إضافة بودكاست", + "ButtonAddUser": "إضافة مستخدم", + "ButtonAddYourFirstLibrary": "أضف مكتبتك الأولى", + "ButtonApply": "حفظ", + "ButtonApplyChapters": "حفظ الفصول", + "ButtonAuthors": "المؤلفون", + "ButtonBack": "الرجوع", + "ButtonBrowseForFolder": "البحث عن المجلد", + "ButtonCancel": "إلغاء", + "ButtonCancelEncode": "إلغاء الترميز", + "ButtonChangeRootPassword": "تغيير كلمة المرور الرئيسية", + "ButtonCheckAndDownloadNewEpisodes": "التحقق من الحلقات الجديدة وتنزيلها", + "ButtonChooseAFolder": "اختر المجلد", + "ButtonChooseFiles": "اختر الملفات", + "ButtonClearFilter": "تصفية الفرز", + "ButtonCloseFeed": "إغلاق", + "ButtonCloseSession": "إغلاق الجلسة المفتوحة", + "ButtonCollections": "المجموعات", + "ButtonConfigureScanner": "إعدادات الماسح الضوئي", + "ButtonCreate": "إنشاء", + "ButtonCreateBackup": "إنشاء نسخة احتياطية", + "ButtonDelete": "حذف", + "ButtonDownloadQueue": "قائمة", + "ButtonEdit": "تعديل", + "ButtonEditChapters": "تعديل الفصول", + "ButtonEditPodcast": "تعديل البودكاست", + "ButtonEnable": "تفعيل", + "ButtonForceReScan": "فرض إعادة المسح", + "ButtonFullPath": "المسار الكامل", + "ButtonHide": "إخفاء", + "ButtonHome": "الرئيسية", + "ButtonIssues": "مشاكل", + "ButtonJumpBackward": "اقفز للخلف", + "ButtonJumpForward": "اقفز للأمام", + "ButtonLatest": "أحدث", + "ButtonLibrary": "المكتبة", + "ButtonLogout": "تسجيل الخروج" +} From 6786df6965df49c498603e34137b8c7a5dfb1321 Mon Sep 17 00:00:00 2001 From: Julio Cesar de jesus Date: Sat, 16 Nov 2024 23:05:38 +0000 Subject: [PATCH 386/539] Translated using Weblate (Spanish) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index 06aa1b8b52..b45d253457 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -1,6 +1,6 @@ { - "ButtonAdd": "Agregar", - "ButtonAddChapters": "Agregar Capitulo", + "ButtonAdd": "Agregaro", + "ButtonAddChapters": "Agregar", "ButtonAddDevice": "Agregar Dispositivo", "ButtonAddLibrary": "Crear Biblioteca", "ButtonAddPodcasts": "Agregar Podcasts", From 10a7cd0987e0068f63094732639aa9f004f743a0 Mon Sep 17 00:00:00 2001 From: Julio Cesar de jesus Date: Sat, 16 Nov 2024 23:02:17 +0000 Subject: [PATCH 387/539] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index e4aecc90ad..81cd13f4b7 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -928,7 +928,7 @@ "ToastBackupCreateSuccess": "Резервну копію створено", "ToastBackupDeleteFailed": "Не вдалося видалити резервну копію", "ToastBackupDeleteSuccess": "Резервну копію видалено", - "ToastBackupInvalidMaxKeep": "Невірна кількість резервних копій для зберігання", + "ToastBackupInvalidMaxKeep": "Профіль оновленоПрофіль оновлено", "ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії", "ToastBackupRestoreFailed": "Не вдалося відновити резервну копію", "ToastBackupUploadFailed": "Не вдалося завантажити резервну копію", From fe25d1dccda12521a637c91dc3865b4ecd0a0a6b Mon Sep 17 00:00:00 2001 From: Mohamad Dahhan Date: Sat, 16 Nov 2024 23:53:31 +0000 Subject: [PATCH 388/539] Translated using Weblate (Arabic) Currently translated at 11.9% (128 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 90 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index 673b0238f5..c4891f1951 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -30,6 +30,8 @@ "ButtonEditChapters": "تعديل الفصول", "ButtonEditPodcast": "تعديل البودكاست", "ButtonEnable": "تفعيل", + "ButtonFireAndFail": "النار والفشل", + "ButtonFireOnTest": "حادثة إطلاق النار", "ButtonForceReScan": "فرض إعادة المسح", "ButtonFullPath": "المسار الكامل", "ButtonHide": "إخفاء", @@ -39,5 +41,91 @@ "ButtonJumpForward": "اقفز للأمام", "ButtonLatest": "أحدث", "ButtonLibrary": "المكتبة", - "ButtonLogout": "تسجيل الخروج" + "ButtonLogout": "تسجيل الخروج", + "ButtonLookup": "البحث", + "ButtonManageTracks": "إدارة المقاطع", + "ButtonMapChapterTitles": "مطابقة عناوين الفصول", + "ButtonMatchAllAuthors": "مطابقة كل المؤلفون", + "ButtonMatchBooks": "مطابقة الكتب", + "ButtonNevermind": "لا تهتم", + "ButtonNext": "التالي", + "ButtonNextChapter": "الفصل التالي", + "ButtonNextItemInQueue": "العنصر التالي في قائمة الانتظار", + "ButtonOk": "نعم", + "ButtonOpenFeed": "فتح التغذية", + "ButtonOpenManager": "فتح الإدارة", + "ButtonPause": "تَوَقَّف", + "ButtonPlay": "تشغيل", + "ButtonPlayAll": "تشغيل الكل", + "ButtonPlaying": "مشغل الآن", + "ButtonPlaylists": "قوائم التشغيل", + "ButtonPrevious": "سابِق", + "ButtonPreviousChapter": "الفصل السابق", + "ButtonProbeAudioFile": "فحص ملف الصوت", + "ButtonPurgeAllCache": "مسح كافة ذاكرة التخزين المؤقتة", + "ButtonPurgeItemsCache": "مسح ذاكرة التخزين المؤقتة للعناصر", + "ButtonQueueAddItem": "أضف إلى قائمة الانتظار", + "ButtonQueueRemoveItem": "إزالة من قائمة الانتظار", + "ButtonQuickEmbed": "التضمين السريع", + "ButtonQuickEmbedMetadata": "إدراج سريع للبيانات الوصفية", + "ButtonQuickMatch": "مطابقة سريعة", + "ButtonReScan": "إعادة البحث", + "ButtonRead": "اقرأ", + "ButtonReadLess": "قلص", + "ButtonReadMore": "المزيد", + "ButtonRefresh": "تحديث", + "ButtonRemove": "إزالة", + "ButtonRemoveAll": "إزالة الكل", + "ButtonRemoveAllLibraryItems": "إزالة كافة عناصر المكتبة", + "ButtonRemoveFromContinueListening": "إزالة من متابعة الاستماع", + "ButtonRemoveFromContinueReading": "إزالة من متابعة القراءة", + "ButtonRemoveSeriesFromContinueSeries": "إزالة السلسلة من استمرار السلسلة", + "ButtonReset": "إعادة ضبط", + "ButtonResetToDefault": "إعادة ضبط إلى الوضع الافتراضي", + "ButtonRestore": "إستِعادة", + "ButtonSave": "حفظ", + "ButtonSaveAndClose": "حفظ و إغلاق", + "ButtonSaveTracklist": "حفظ قائمة التشغيل", + "ButtonScan": "تَحَقُق", + "ButtonScanLibrary": "تَحَقُق من المكتبة", + "ButtonSearch": "بحث", + "ButtonSelectFolderPath": "حدد مسار المجلد", + "ButtonSeries": "سلسلة", + "ButtonSetChaptersFromTracks": "تعيين الفصول من الملفات", + "ButtonShare": "نشر", + "ButtonShiftTimes": "أوقات العمل", + "ButtonShow": "عرض", + "ButtonStartM4BEncode": "ابدأ ترميز M4B", + "ButtonStartMetadataEmbed": "ابدأ تضمين البيانات الوصفية", + "ButtonStats": "الإحصائيات", + "ButtonSubmit": "تقديم", + "ButtonTest": "اختبار", + "ButtonUnlinkOpenId": "إلغاء ربط المعرف", + "ButtonUpload": "رفع", + "ButtonUploadBackup": "تحميل النسخة الاحتياطية", + "ButtonUploadCover": "ارفق الغلاف", + "ButtonUploadOPMLFile": "رفع ملف OPML", + "ButtonUserDelete": "حذف المستخدم {0}", + "ButtonUserEdit": "تعديل المستخدم {0}", + "ButtonViewAll": "عرض الكل", + "ButtonYes": "نعم", + "ErrorUploadFetchMetadataAPI": "خطأ في جلب البيانات الوصفية", + "ErrorUploadFetchMetadataNoResults": "لم يتم العثور على البيانات الوصفية - حاول تحديث العنوان و/أو المؤلف", + "ErrorUploadLacksTitle": "يجب أن يكون له عنوان", + "HeaderAccount": "الحساب", + "HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص", + "HeaderAdvanced": "متقدم", + "HeaderAppriseNotificationSettings": "إعدادات الإشعارات", + "HeaderAudioTracks": "المسارات الصوتية", + "HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية", + "HeaderAuthentication": "المصادقة", + "HeaderBackups": "النسخ الاحتياطية", + "HeaderChangePassword": "تغيير كلمة المرور", + "HeaderChapters": "الفصول", + "HeaderChooseAFolder": "اختيار المجلد", + "HeaderCollection": "مجموعة", + "HeaderCollectionItems": "عناصر المجموعة", + "HeaderCover": "الغلاف", + "HeaderCurrentDownloads": "التنزيلات الجارية", + "HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول" } From 2b0ba7d1e28f8cc0bcce153cbcde1a06927f93d9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 16:25:40 -0600 Subject: [PATCH 389/539] Version bump v2.17.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index f31266cbf2..49fd4fa3bb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 2feb833b25..f579b8681e 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 6e3276cea2..17d8403e96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index d31f202202..d7aa3261c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 778256ca165fb1248cdb5463146ac4e0561f2c82 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 07:42:24 -0600 Subject: [PATCH 390/539] Fix:Server crash on new libraries when getting filter data #3623 --- server/utils/queries/libraryFilters.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 64ad07ee08..be164eb23f 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -510,7 +510,7 @@ module.exports = { // If nothing has changed, check if the number of podcasts in // library is still the same as prior check before updating cache creation time - if (podcastCountFromDatabase === Database.libraryFilterData[libraryId].podcastCount) { + if (podcastCountFromDatabase === Database.libraryFilterData[libraryId]?.podcastCount) { Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) Database.libraryFilterData[libraryId].loadedAt = Date.now() return cachedFilterData @@ -613,7 +613,7 @@ module.exports = { if (changedBooks + changedSeries + changedAuthors === 0) { // If nothing has changed, check if the number of authors, series, and books // matches the prior check before updating cache creation time - if (bookCountFromDatabase === Database.libraryFilterData[libraryId].bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId].seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { + if (bookCountFromDatabase === Database.libraryFilterData[libraryId]?.bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId]?.seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) Database.libraryFilterData[libraryId].loadedAt = Date.now() return cachedFilterData From a5e38d14737ff8d43ed5b12f5f782978961b532c Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 07:59:02 -0600 Subject: [PATCH 391/539] Fix:Error adding new series if a series has a null title #3622 --- client/components/widgets/SeriesInputWidget.vue | 2 -- server/objects/metadata/BookMetadata.js | 7 ++++++- server/utils/queries/libraryFilters.js | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/components/widgets/SeriesInputWidget.vue b/client/components/widgets/SeriesInputWidget.vue index e770eed3fb..1d8b64feb8 100644 --- a/client/components/widgets/SeriesInputWidget.vue +++ b/client/components/widgets/SeriesInputWidget.vue @@ -71,8 +71,6 @@ export default { this.showSeriesForm = true }, submitSeriesForm() { - console.log('submit series form', this.value, this.selectedSeries) - if (!this.selectedSeries.name) { this.$toast.error('Must enter a series') return diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 6d3dae4326..c6192f116c 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -29,7 +29,12 @@ class BookMetadata { this.subtitle = metadata.subtitle this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : [] this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : [] - this.series = metadata.series?.map ? metadata.series.map((s) => ({ ...s })) : [] + this.series = metadata.series?.map + ? metadata.series.map((s) => ({ + ...s, + name: s.name || 'No Title' + })) + : [] this.genres = metadata.genres ? [...metadata.genres] : [] this.publishedYear = metadata.publishedYear || null this.publishedDate = metadata.publishedDate || null diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index be164eb23f..bdddde7595 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -662,7 +662,7 @@ module.exports = { }, attributes: ['id', 'name'] }) - series.forEach((s) => data.series.push({ id: s.id, name: s.name })) + series.forEach((s) => data.series.push({ id: s.id, name: s.name || 'No Title' })) const authors = await Database.authorModel.findAll({ where: { From 4adb15c11b209c045a88a43416d8dbd7b60a474f Mon Sep 17 00:00:00 2001 From: Clara Papke Date: Mon, 18 Nov 2024 09:33:40 +0000 Subject: [PATCH 392/539] Translated using Weblate (German) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index a427c2885b..6dff93381b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -71,8 +71,8 @@ "ButtonQuickMatch": "Schnellabgleich", "ButtonReScan": "Neu scannen", "ButtonRead": "Lesen", - "ButtonReadLess": "Weniger anzeigen", - "ButtonReadMore": "Mehr anzeigen", + "ButtonReadLess": "weniger Anzeigen", + "ButtonReadMore": "Mehr Anzeigen", "ButtonRefresh": "Neu Laden", "ButtonRemove": "Entfernen", "ButtonRemoveAll": "Alles entfernen", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", "LabelAddedAt": "Hinzugefügt am", - "LabelAddedDate": "Hinzugefügt {0}", + "LabelAddedDate": "{0} Hinzugefügt", "LabelAdminUsersOnly": "Nur Admin Benutzer", "LabelAll": "Alle", "LabelAllUsers": "Alle Benutzer", @@ -534,6 +534,7 @@ "LabelSelectUsers": "Benutzer auswählen", "LabelSendEbookToDevice": "E-Buch senden an …", "LabelSequence": "Reihenfolge", + "LabelSerial": "fortlaufend", "LabelSeries": "Serien", "LabelSeriesName": "Serienname", "LabelSeriesProgress": "Serienfortschritt", @@ -680,8 +681,8 @@ "LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelXBooks": "{0} Bücher", "LabelXItems": "{0} Medien", - "LabelYearReviewHide": "Verstecke Jahr in Übersicht", - "LabelYearReviewShow": "Zeige Jahr in Übersicht", + "LabelYearReviewHide": "Jahresrückblick verbergen", + "LabelYearReviewShow": "Jahresrückblick anzeigen", "LabelYourAudiobookDuration": "Laufzeit deines Mediums", "LabelYourBookmarks": "Lesezeichen", "LabelYourPlaylists": "Eigene Wiedergabelisten", From dd3467efa2071675e110296feaa1f773ae8977e4 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 18 Nov 2024 09:13:14 +0000 Subject: [PATCH 393/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index bbcf8055a2..366c8479b3 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -495,7 +495,7 @@ "LabelProviderAuthorizationValue": "Vrednost glave avtorizacije", "LabelPubDate": "Datum objave", "LabelPublishYear": "Leto izdaje", - "LabelPublishedDate": "Izdano {0}", + "LabelPublishedDate": "Objavljeno {0}", "LabelPublishedDecade": "Desetletje izdaje", "LabelPublishedDecades": "Desetletja izdaje", "LabelPublisher": "Izdajatelj", @@ -682,7 +682,7 @@ "LabelXBooks": "{0} knjig", "LabelXItems": "{0} elementov", "LabelYearReviewHide": "Skrij pregled leta", - "LabelYearReviewShow": "Poglej pregled leta", + "LabelYearReviewShow": "Poglej si pregled leta", "LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig", "LabelYourBookmarks": "Tvoji zaznamki", "LabelYourPlaylists": "Tvoje seznami predvajanj", From 22f85d3af9815f4946eeeb2218d532cf5f543da8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 08:02:46 -0600 Subject: [PATCH 394/539] Version bump v2.17.1 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 49fd4fa3bb..c7d01fb45c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index f579b8681e..361130eac1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 17d8403e96..96d85ececc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index d7aa3261c2..ec15388905 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From ee6e2d2983f1a84f5b7fae4922b72757e8a751d4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 19 Nov 2024 16:48:05 -0600 Subject: [PATCH 395/539] Update:Persist podcast episode table sort and filter options in local storage #1321 --- client/components/tables/podcast/LazyEpisodesTable.vue | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/components/tables/podcast/LazyEpisodesTable.vue b/client/components/tables/podcast/LazyEpisodesTable.vue index 963cd7c96b..0dae11b36b 100644 --- a/client/components/tables/podcast/LazyEpisodesTable.vue +++ b/client/components/tables/podcast/LazyEpisodesTable.vue @@ -25,7 +25,6 @@
    -
    @@ -515,6 +514,10 @@ export default { } }, filterSortChanged() { + // Save filterKey and sortKey to local storage + localStorage.setItem('podcastEpisodesFilter', this.filterKey) + localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : '')) + this.init() }, refresh() { @@ -537,6 +540,11 @@ export default { } }, mounted() { + this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete' + const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc' + this.sortKey = sortBy.split('-')[0] + this.sortDesc = sortBy.split('-')[1] === 'desc' + this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) this.initListeners() this.init() From ff026a06bbfbd974032a58bfd32c67c53f0aebff Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 20 Nov 2024 16:48:09 -0600 Subject: [PATCH 396/539] Fix v2.17.0 migration to ensure mediaItemShares table exists --- server/migrations/v2.17.0-uuid-replacement.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/migrations/v2.17.0-uuid-replacement.js b/server/migrations/v2.17.0-uuid-replacement.js index 6460b79526..4316cd7694 100644 --- a/server/migrations/v2.17.0-uuid-replacement.js +++ b/server/migrations/v2.17.0-uuid-replacement.js @@ -27,10 +27,14 @@ async function up({ context: { queryInterface, logger } }) { type: 'UUID' }) - logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') - await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { - type: 'UUID' - }) + if (await queryInterface.tableExists('mediaItemShares')) { + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUID' + }) + } else { + logger.info('[2.17.0 migration] mediaItemShares table does not exist, skipping column change') + } logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID') await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { From fc5f35b3887044e057367331dbc384d12522db70 Mon Sep 17 00:00:00 2001 From: Harrison Rose Date: Thu, 21 Nov 2024 02:06:53 +0000 Subject: [PATCH 397/539] on iOS, do not restrict file types for upload --- client/pages/upload/index.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 0efa1456d1..8bc57de544 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -84,7 +84,7 @@
    - +
    @@ -127,6 +127,10 @@ export default { }) return extensions }, + isIOS() { + const ua = window.navigator.userAgent + return /iPad|iPhone|iPod/.test(ua) && !window.MSStream + }, streamLibraryItem() { return this.$store.state.streamLibraryItem }, From 268fb2ce9a29ff5acce81d030537141fca2a7bc1 Mon Sep 17 00:00:00 2001 From: Harrison Rose Date: Thu, 21 Nov 2024 04:43:03 +0000 Subject: [PATCH 398/539] on iOS, hide UI on upload page related to folder selection (since iOS Webkit does not support folder selection) --- client/pages/upload/index.vue | 8 ++++---- client/strings/en-us.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 8bc57de544..7c1b476765 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -34,12 +34,12 @@
    -

    {{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}

    +

    {{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop + (isIOS ? '' : ' ' + $strings.LabelUploaderDragAndDropOrFolders) }}

    {{ $strings.MessageOr }}

    {{ $strings.ButtonChooseFiles }} - {{ $strings.ButtonChooseAFolder }} + {{ $strings.ButtonChooseAFolder }}
    @@ -48,7 +48,7 @@

    - {{ $strings.NoteUploaderFoldersWithMediaFiles }} {{ $strings.NoteUploaderOnlyAudioFiles }} + {{ $strings.NoteUploaderFoldersWithMediaFiles }} {{ $strings.NoteUploaderOnlyAudioFiles }}

    @@ -85,7 +85,7 @@
- +
diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 8eb375500d..e6392c0f90 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -662,7 +662,8 @@ "LabelUpdateDetails": "Update Details", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdatedAt": "Updated At", - "LabelUploaderDragAndDrop": "Drag & drop files or folders", + "LabelUploaderDragAndDrop": "Drag & drop files", + "LabelUploaderDragAndDropOrFolders": "or folders", "LabelUploaderDropFiles": "Drop files", "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", "LabelUseAdvancedOptions": "Use Advanced Options", From 784b761629af9212d34cdf36d01005c221b125f6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 21 Nov 2024 14:19:40 -0600 Subject: [PATCH 399/539] Fix:Unable to edit series sequence #3636 --- server/models/LibraryItem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 5b96ad5211..10395c49c2 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -479,7 +479,7 @@ class LibraryItem extends Model { { model: this.sequelize.models.series, through: { - attributes: ['sequence'] + attributes: ['id', 'sequence'] } } ], From 1d4e6993fc09a954b150eeaed69156559cc892c8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 21 Nov 2024 14:56:43 -0600 Subject: [PATCH 400/539] Upload page UI updates for mobile --- client/mixins/uploadHelpers.js | 32 ++++++++++++++++---------------- client/pages/upload/index.vue | 16 ++++++++-------- client/strings/en-us.json | 4 ++-- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/client/mixins/uploadHelpers.js b/client/mixins/uploadHelpers.js index 2d7a554f0a..994d36c696 100644 --- a/client/mixins/uploadHelpers.js +++ b/client/mixins/uploadHelpers.js @@ -28,10 +28,8 @@ export default { var validOtherFiles = [] var ignoredFiles = [] files.forEach((file) => { - // var filetype = this.checkFileType(file.name) if (!file.filetype) ignoredFiles.push(file) else { - // file.filetype = filetype if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file) else validOtherFiles.push(file) } @@ -165,7 +163,7 @@ export default { var firstBookPath = Path.dirname(firstBookFile.filepath) - var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.') + var dirs = firstBookPath.split('/').filter((d) => !!d && d !== '.') if (dirs.length) { audiobook.title = dirs.pop() if (dirs.length > 1) { @@ -189,7 +187,7 @@ export default { var firstAudioFile = podcast.itemFiles[0] if (!firstAudioFile.filepath) return podcast // No path var firstPath = Path.dirname(firstAudioFile.filepath) - var dirs = firstPath.split('/').filter(d => !!d && d !== '.') + var dirs = firstPath.split('/').filter((d) => !!d && d !== '.') if (dirs.length) { podcast.title = dirs.length > 1 ? dirs[1] : dirs[0] } else { @@ -212,13 +210,15 @@ export default { } var ignoredFiles = itemData.ignoredFiles var index = 1 - var items = itemData.items.filter((ab) => { - if (!ab.itemFiles.length) { - if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles) - if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) - } - return ab.itemFiles.length - }).map(ab => this.cleanItem(ab, mediaType, index++)) + var items = itemData.items + .filter((ab) => { + if (!ab.itemFiles.length) { + if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles) + if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) + } + return ab.itemFiles.length + }) + .map((ab) => this.cleanItem(ab, mediaType, index++)) return { items, ignoredFiles @@ -259,7 +259,7 @@ export default { otherFiles.forEach((file) => { var dir = Path.dirname(file.filepath) - var findItem = Object.values(itemMap).find(b => dir.startsWith(b.path)) + var findItem = Object.values(itemMap).find((b) => dir.startsWith(b.path)) if (findItem) { findItem.otherFiles.push(file) } else { @@ -270,18 +270,18 @@ export default { var items = [] var index = 1 // If book media type and all files are audio files then treat each one as an audiobook - if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) { + if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some((f) => f.filetype !== 'audio')) { items = itemMap[''].itemFiles.map((audioFile) => { return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++) }) } else { - items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++)) + items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++)) } return { items, ignoredFiles: ignoredFiles } - }, + } } -} \ No newline at end of file +} diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 7c1b476765..441ce88eea 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -1,20 +1,20 @@ @@ -54,7 +71,7 @@ export default { return this.episode.description || '' }, media() { - return this.libraryItem ? this.libraryItem.media || {} : {} + return this.libraryItem?.media || {} }, mediaMetadata() { return this.media.metadata || {} @@ -65,6 +82,14 @@ export default { podcastAuthor() { return this.mediaMetadata.author }, + audioFileFilename() { + return this.episode.audioFile?.metadata?.filename || '' + }, + audioFileSize() { + const size = this.episode.audioFile?.metadata?.size || 0 + + return this.$bytesPretty(size) + }, bookCoverAspectRatio() { return this.$store.getters['libraries/getBookCoverAspectRatio'] } From fabdfd5517f805727e50cb25f718564ea68a23af Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Tue, 26 Nov 2024 04:04:44 +0000 Subject: [PATCH 412/539] Add player settings modal to PlayerUi --- client/components/player/PlayerUi.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index 92179580b4..d4fdb8f729 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -37,7 +37,7 @@ - @@ -64,6 +64,8 @@
+ +
@@ -96,6 +98,7 @@ export default { audioEl: null, seekLoading: false, showChaptersModal: false, + showPlayerSettingsModal: false, currentTime: 0, duration: 0 } @@ -315,6 +318,9 @@ export default { if (!this.chapters.length) return this.showChaptersModal = !this.showChaptersModal }, + showPlayerSettings() { + this.showPlayerSettingsModal = !this.showPlayerSettingsModal + }, init() { this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 From 53fdb5273ca215b2c257857ddcf660eb08cb6777 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Tue, 26 Nov 2024 04:04:55 +0000 Subject: [PATCH 413/539] Remove player settings modal from MediaPlayerContainer --- client/components/app/MediaPlayerContainer.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue index 1a19f30199..ed8971f790 100644 --- a/client/components/app/MediaPlayerContainer.vue +++ b/client/components/app/MediaPlayerContainer.vue @@ -53,7 +53,6 @@ @showBookmarks="showBookmarks" @showSleepTimer="showSleepTimerModal = true" @showPlayerQueueItems="showPlayerQueueItemsModal = true" - @showPlayerSettings="showPlayerSettingsModal = true" /> @@ -61,8 +60,6 @@ - -
@@ -81,7 +78,6 @@ export default { currentTime: 0, showSleepTimerModal: false, showPlayerQueueItemsModal: false, - showPlayerSettingsModal: false, sleepTimerSet: false, sleepTimerRemaining: 0, sleepTimerType: null, From 2ba0f9157d1591e930e311943862278f65c91557 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 26 Nov 2024 17:03:01 -0600 Subject: [PATCH 414/539] Update share player to load user settings --- client/components/modals/PlayerSettingsModal.vue | 13 ++++++++++--- client/pages/share/_slug.vue | 5 ++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/client/components/modals/PlayerSettingsModal.vue b/client/components/modals/PlayerSettingsModal.vue index ec178d9c3a..88cb91e188 100644 --- a/client/components/modals/PlayerSettingsModal.vue +++ b/client/components/modals/PlayerSettingsModal.vue @@ -59,12 +59,19 @@ export default { setJumpBackwardAmount(val) { this.jumpBackwardAmount = val this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val }) + }, + settingsUpdated() { + this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') + this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') + this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') } }, mounted() { - this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') - this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') - this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') + this.settingsUpdated() + this.$eventBus.$on('user-settings', this.settingsUpdated) + }, + beforeDestroy() { + this.$eventBus.$off('user-settings', this.settingsUpdated) } } diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index cd990072b0..89e159c139 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -126,7 +126,8 @@ export default { if (!this.localAudioPlayer || !this.hasLoaded) return const currentTime = this.localAudioPlayer.getCurrentTime() const duration = this.localAudioPlayer.getDuration() - this.seek(Math.min(currentTime + 10, duration)) + const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10 + this.seek(Math.min(currentTime + jumpForwardAmount, duration)) }, jumpBackward() { if (!this.localAudioPlayer || !this.hasLoaded) return @@ -248,6 +249,8 @@ export default { } }, mounted() { + this.$store.dispatch('user/loadUserSettings') + this.resize() window.addEventListener('resize', this.resize) window.addEventListener('keydown', this.keyDown) From 718d8b599993c676762dae07bd09a73c65971490 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 26 Nov 2024 17:05:50 -0600 Subject: [PATCH 415/539] Update jump backward amount for share player --- client/pages/share/_slug.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index 89e159c139..7ddb994c9c 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -132,7 +132,8 @@ export default { jumpBackward() { if (!this.localAudioPlayer || !this.hasLoaded) return const currentTime = this.localAudioPlayer.getCurrentTime() - this.seek(Math.max(currentTime - 10, 0)) + const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10 + this.seek(Math.max(currentTime - jumpBackwardAmount, 0)) }, setVolume(volume) { if (!this.localAudioPlayer || !this.hasLoaded) return From ef82e8b0d0760b40a1ab7ac94ceb4af94c046f13 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 27 Nov 2024 16:48:07 -0600 Subject: [PATCH 416/539] Fix:Server crash deleting user with sessions --- server/controllers/UserController.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index f895c0d014..0fb1051337 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -368,6 +368,19 @@ class UserController { await playlist.destroy() } + // Set PlaybackSessions userId to null + const [sessionsUpdated] = await Database.playbackSessionModel.update( + { + userId: null + }, + { + where: { + userId: user.id + } + } + ) + Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`) + const userJson = user.toOldJSONForBrowser() await user.destroy() SocketAuthority.adminEmitter('user_removed', userJson) From 70f466d03c4c27d99070d764a2eddac0bdccc9f8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 28 Nov 2024 17:18:34 -0600 Subject: [PATCH 417/539] Add migration for v2.17.3 to fix dropped fk constraints --- server/migrations/changelog.md | 13 +- server/migrations/v2.17.3-fk-constraints.js | 219 ++++++++++++++++++++ 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 server/migrations/v2.17.3-fk-constraints.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8960ade2f7..51e826000a 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,10 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | diff --git a/server/migrations/v2.17.3-fk-constraints.js b/server/migrations/v2.17.3-fk-constraints.js new file mode 100644 index 0000000000..a62307a3e3 --- /dev/null +++ b/server/migrations/v2.17.3-fk-constraints.js @@ -0,0 +1,219 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes foreign key constraints for the + * libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints') + + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + + // Disable foreign key constraints for the next sequence of operations + await execQuery(`PRAGMA foreign_keys = OFF;`) + + try { + await execQuery(`BEGIN TRANSACTION;`) + + logger.info('[2.17.3 migration] Updating libraryItems constraints') + const libraryItemsConstraints = [ + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints) + logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + + logger.info('[2.17.3 migration] Updating feeds constraints') + const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'feeds', feedsConstraints) + logger.info('[2.17.3 migration] Finished updating feeds constraints') + + if (await queryInterface.tableExists('mediaItemShares')) { + logger.info('[2.17.3 migration] Updating mediaItemShares constraints') + const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints) + logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + } else { + logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change') + } + + logger.info('[2.17.3 migration] Updating playbackSessions constraints') + const playbackSessionsConstraints = [ + { field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints) + logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + + logger.info('[2.17.3 migration] Updating playlistMediaItems constraints') + const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints) + logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + + logger.info('[2.17.3 migration] Updating mediaProgresses constraints') + const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints) + logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + + await execQuery(`COMMIT;`) + } catch (error) { + logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error) + await execQuery(`ROLLBACK;`) + } + + await execQuery(`PRAGMA foreign_keys = ON;`) + + // Completed migration + logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints') +} + +/** + * This downward migration script is a no-op. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints') + + // This migration is a no-op + logger.info('[2.17.3 migration] No action required for downgrade') + + // Completed migration + logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints') +} + +/** + * @typedef ConstraintUpdateObj + * @property {string} field - The field to update + * @property {string} onDelete - The onDelete constraint + * @property {string} onUpdate - The onUpdate constraint + */ + +const formatFKsPragmaToSequelizeFK = (fk) => { + let onDelete = fk['on_delete'] + let onUpdate = fk['on_update'] + + if (fk.from === 'userId' || fk.from === 'libraryId' || fk.from === 'deviceId') { + onDelete = 'SET NULL' + onUpdate = 'CASCADE' + } + + return { + references: { + model: fk.table, + key: fk.to + }, + constraints: { + onDelete, + onUpdate + } + } +} + +/** + * Extends the Sequelize describeTable function to include the foreign keys constraints in sqlite dbs + * @param {import('sequelize').QueryInterface} queryInterface + * @param {String} tableName - The table name + * @param {ConstraintUpdateObj[]} constraints - constraints to update + */ +async function describeTableWithFKs(queryInterface, tableName, constraints) { + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + const quotedTableName = queryInterface.quoteIdentifier(tableName) + + const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`) + + const foreignKeysByColName = foreignKeys.reduce((prev, curr) => { + const fk = formatFKsPragmaToSequelizeFK(curr) + return { ...prev, [curr.from]: fk } + }, {}) + + const tableDescription = await queryInterface.describeTable(tableName) + + const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => { + let extendedAttributes = attributes + + if (foreignKeysByColName[col]) { + // Use the constraints from the constraints array if they exist, otherwise use the existing constraints + const onDelete = constraints.find((c) => c.field === col)?.onDelete || foreignKeysByColName[col].constraints.onDelete + const onUpdate = constraints.find((c) => c.field === col)?.onUpdate || foreignKeysByColName[col].constraints.onUpdate + + extendedAttributes = { + ...extendedAttributes, + references: foreignKeysByColName[col].references, + onDelete, + onUpdate + } + } + return { ...prev, [col]: extendedAttributes } + }, {}) + + return tableDescriptionWithFks +} + +/** + * @see https://www.sqlite.org/lang_altertable.html#otheralter + * @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {string} tableName + * @param {ConstraintUpdateObj[]} constraints + */ +async function changeConstraints(queryInterface, tableName, constraints) { + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + const quotedTableName = queryInterface.quoteIdentifier(tableName) + + const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup` + const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName) + + try { + const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, constraints) + + const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks) + + // Create the backup table + await queryInterface.createTable(backupTableName, attributes) + + const attributeNames = Object.keys(attributes) + .map((attr) => queryInterface.quoteIdentifier(attr)) + .join(', ') + + // Copy all data from the target table to the backup table + await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`) + + // Drop the old (original) table + await queryInterface.dropTable(tableName) + + // Rename the backup table to the original table's name + await queryInterface.renameTable(backupTableName, tableName) + + // Validate that all foreign key constraints are correct + const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, { + type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT + }) + + // There are foreign key violations, exit + if (result.length) { + return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`) + } + + return Promise.resolve() + } catch (error) { + return Promise.reject(error) + } +} + +module.exports = { up, down } From 843dd0b1b28ec1e5f36b71eee58af7306e84a4ef Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 04:13:00 +0200 Subject: [PATCH 418/539] Keep original socket.io server for non-subdir clients --- server/Server.js | 18 ++--- server/SocketAuthority.js | 142 ++++++++++++++++++++++---------------- 2 files changed, 87 insertions(+), 73 deletions(-) diff --git a/server/Server.js b/server/Server.js index ae9746d8d4..9153ab0921 100644 --- a/server/Server.js +++ b/server/Server.js @@ -84,7 +84,6 @@ class Server { Logger.logManager = new LogManager() this.server = null - this.io = null } /** @@ -441,18 +440,11 @@ class Server { async stop() { Logger.info('=== Stopping Server ===') Watcher.close() - Logger.info('Watcher Closed') - - return new Promise((resolve) => { - SocketAuthority.close((err) => { - if (err) { - Logger.error('Failed to close server', err) - } else { - Logger.info('Server successfully closed') - } - resolve() - }) - }) + Logger.info('[Server] Watcher Closed') + await SocketAuthority.close() + Logger.info('[Server] Closing HTTP Server') + await new Promise((resolve) => this.server.close(resolve)) + Logger.info('[Server] HTTP Server Closed') } } module.exports = Server diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index a718293617..19c686d972 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -14,7 +14,7 @@ const Auth = require('./Auth') class SocketAuthority { constructor() { this.Server = null - this.io = null + this.socketIoServers = [] /** @type {Object.} */ this.clients = {} @@ -89,82 +89,104 @@ class SocketAuthority { * * @param {Function} callback */ - close(callback) { - Logger.info('[SocketAuthority] Shutting down') - // This will close all open socket connections, and also close the underlying http server - if (this.io) this.io.close(callback) - else callback() + async close() { + Logger.info('[SocketAuthority] closing...') + const closePromises = this.socketIoServers.map((io) => { + return new Promise((resolve) => { + Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`) + io.close(() => { + Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`) + resolve() + }) + }) + }) + await Promise.all(closePromises) + Logger.info('[SocketAuthority] closed') + this.socketIoServers = [] } initialize(Server) { this.Server = Server - this.io = new SocketIO.Server(this.Server.server, { + const socketIoOptions = { cors: { origin: '*', methods: ['GET', 'POST'] - }, - path: `${global.RouterBasePath}/socket.io` - }) - - this.io.on('connection', (socket) => { - this.clients[socket.id] = { - id: socket.id, - socket, - connected_at: Date.now() } - socket.sheepClient = this.clients[socket.id] + } - Logger.info('[SocketAuthority] Socket Connected', socket.id) + const ioServer = new SocketIO.Server(Server.server, socketIoOptions) + ioServer.path = '/socket.io' + this.socketIoServers.push(ioServer) - // Required for associating a User with a socket - socket.on('auth', (token) => this.authenticateSocket(socket, token)) + if (global.RouterBasePath) { + // open a separate socket.io server for the router base path, keeping the original server open for legacy clients + const ioBasePath = `${global.RouterBasePath}/socket.io` + const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath }) + ioBasePathServer.path = ioBasePath + this.socketIoServers.push(ioBasePathServer) + } - // Scanning - socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) + this.socketIoServers.forEach((io) => { + io.on('connection', (socket) => { + this.clients[socket.id] = { + id: socket.id, + socket, + connected_at: Date.now() + } + socket.sheepClient = this.clients[socket.id] - // Logs - socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) - socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) + Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id) - // Sent automatically from socket.io clients - socket.on('disconnect', (reason) => { - Logger.removeSocketListener(socket.id) + // Required for associating a User with a socket + socket.on('auth', (token) => this.authenticateSocket(socket, token)) - const _client = this.clients[socket.id] - if (!_client) { - Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`) - } else if (!_client.user) { - Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`) - delete this.clients[socket.id] - } else { - Logger.debug('[SocketAuthority] User Offline ' + _client.user.username) - this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) + // Scanning + socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) - const disconnectTime = Date.now() - _client.connected_at - Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`) - delete this.clients[socket.id] - } - }) + // Logs + socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) + socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) - // - // Events for testing - // - socket.on('message_all_users', (payload) => { - // admin user can send a message to all authenticated users - // displays on the web app as a toast - const client = this.clients[socket.id] || {} - if (client.user?.isAdminOrUp) { - this.emitter('admin_message', payload.message || '') - } else { - Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`) - } - }) - socket.on('ping', () => { - const client = this.clients[socket.id] || {} - const user = client.user || {} - Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`) - socket.emit('pong') + // Sent automatically from socket.io clients + socket.on('disconnect', (reason) => { + Logger.removeSocketListener(socket.id) + + const _client = this.clients[socket.id] + if (!_client) { + Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`) + } else if (!_client.user) { + Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`) + delete this.clients[socket.id] + } else { + Logger.debug('[SocketAuthority] User Offline ' + _client.user.username) + this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) + + const disconnectTime = Date.now() - _client.connected_at + Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`) + delete this.clients[socket.id] + } + }) + + // + // Events for testing + // + socket.on('message_all_users', (payload) => { + // admin user can send a message to all authenticated users + // displays on the web app as a toast + const client = this.clients[socket.id] || {} + if (client.user?.isAdminOrUp) { + this.emitter('admin_message', payload.message || '') + } else { + Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`) + } + }) + socket.on('ping', () => { + const client = this.clients[socket.id] || {} + const user = client.user || {} + Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`) + socket.emit('pong') + }) }) }) } From 6d8720b404722ba328dfe5de95d43061dc1dffdb Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 04:28:50 +0200 Subject: [PATCH 419/539] Subfolder support for OIDC auth --- client/pages/config/authentication.vue | 38 +++++- client/strings/en-us.json | 2 + server/Auth.js | 8 +- server/controllers/MiscController.js | 4 +- server/migrations/changelog.md | 13 +- ....3-use-subfolder-for-oidc-redirect-uris.js | 84 +++++++++++++ server/objects/settings/ServerSettings.js | 6 +- ...e-subfolder-for-oidc-redirect-uris.test.js | 116 ++++++++++++++++++ 8 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js create mode 100644 test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 1f934c88e3..ba4df4c303 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -64,6 +64,20 @@

+

+
+ +
+
+

{{ $strings.LabelWebRedirectURLsDescription }}

+

+ {{ webCallbackURL }} +
+ {{ mobileAppCallbackURL }} +

+
+
+
@@ -164,6 +178,27 @@ export default { value: 'username' } ] + }, + subfolderOptions() { + const options = [ + { + text: 'None', + value: '' + } + ] + if (this.$config.routerBasePath) { + options.push({ + text: this.$config.routerBasePath, + value: this.$config.routerBasePath + }) + } + return options + }, + webCallbackURL() { + return `https://${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback` + }, + mobileAppCallbackURL() { + return `https://${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect` } }, methods: { @@ -325,7 +360,8 @@ export default { }, init() { this.newAuthSettings = { - ...this.authSettings + ...this.authSettings, + authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs } this.enableLocalAuth = this.authMethods.includes('local') this.enableOpenIDAuth = this.authMethods.includes('openid') diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 0c077ed67c..8a91686c10 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "View player settings", "LabelViewQueue": "View player queue", "LabelVolume": "Volume", + "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", + "LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:", "LabelWeekdaysToRun": "Weekdays to run", "LabelXBooks": "{0} books", "LabelXItems": "{0} items", diff --git a/server/Auth.js b/server/Auth.js index b0046799b9..74b767f5b1 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -131,7 +131,7 @@ class Auth { { client: openIdClient, params: { - redirect_uri: '/auth/openid/callback', + redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, scope: 'openid profile email' } }, @@ -480,9 +480,9 @@ class Auth { // for the request to mobile-redirect and as such the session is not shared this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) - redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString() + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() } else { - redirectUri = new URL('/auth/openid/callback', hostUrl).toString() + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() if (req.query.state) { Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) @@ -733,7 +733,7 @@ class Auth { const host = req.get('host') // TODO: ABS does currently not support subfolders for installation // If we want to support it we need to include a config for the serverurl - postLogoutRedirectUri = `${protocol}://${host}/login` + postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` } // else for openid-mobile we keep postLogoutRedirectUri on null // nice would be to redirect to the app here, but for example Authentik does not implement diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index cf901bea03..2a87f2fef6 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -679,9 +679,9 @@ class MiscController { continue } let updatedValue = settingsUpdate[key] - if (updatedValue === '') updatedValue = null + if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null let currentValue = currentAuthenticationSettings[key] - if (currentValue === '') currentValue = null + if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null if (updatedValue !== currentValue) { Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8960ade2f7..8ba4fad00c 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,10 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| Server Version | Migration Script Name | Description | +| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------ | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| v2.17.3 | v2.17.3-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | diff --git a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js b/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js new file mode 100644 index 0000000000..d03783cddf --- /dev/null +++ b/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js @@ -0,0 +1,84 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration adds an subfolder setting for OIDC redirect URIs. + * It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before. + * IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined), + * so that future OIDC setups will use the default subfolder. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris') + + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authActiveAuthMethods?.includes('openid')) { + logger.info('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') + serverSettings.authOpenIDSubfolderForRedirectURLs = '' + await updateServerSettings(queryInterface, logger, serverSettings) + } else { + logger.info('[2.17.3 migration] OIDC is not enabled, no action required') + } + + logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris') +} + +/** + * This downward migration script removes the subfolder setting for OIDC redirect URIs. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + + // Remove the OIDC subfolder option from the server settings + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) { + logger.info('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') + delete serverSettings.authOpenIDSubfolderForRedirectURLs + await updateServerSettings(queryInterface, logger, serverSettings) + } else { + logger.info('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') + } + + logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ') +} + +async function getServerSettings(queryInterface, logger) { + const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";') + if (!result[0].length) { + logger.error('[2.17.3 migration] Server settings not found') + throw new Error('Server settings not found') + } + + let serverSettings = null + try { + serverSettings = JSON.parse(result[0][0].value) + } catch (error) { + logger.error('[2.17.3 migration] Error parsing server settings:', error) + throw error + } + + return serverSettings +} + +async function updateServerSettings(queryInterface, logger, serverSettings) { + await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify(serverSettings) + } + }) +} + +module.exports = { up, down } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 8ecb8ff051..ff28027f5b 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -78,6 +78,7 @@ class ServerSettings { this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] this.authOpenIDGroupClaim = '' this.authOpenIDAdvancedPermsClaim = '' + this.authOpenIDSubfolderForRedirectURLs = undefined if (settings) { this.construct(settings) @@ -139,6 +140,7 @@ class ServerSettings { this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || '' this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' + this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -240,7 +242,8 @@ class ServerSettings { authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client - authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client + authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs } } @@ -286,6 +289,7 @@ class ServerSettings { authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs, authOpenIDSamplePermissions: User.getSampleAbsPermissions() } diff --git a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js b/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js new file mode 100644 index 0000000000..157b1ed41a --- /dev/null +++ b/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js @@ -0,0 +1,116 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris') +const { Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { + let queryInterface, logger, context + + beforeEach(() => { + queryInterface = { + sequelize: { + query: sinon.stub() + } + } + logger = { + info: sinon.stub(), + error: sinon.stub() + } + context = { queryInterface, logger } + }) + + describe('up', () => { + it('should add authOpenIDSubfolderForRedirectURLs if OIDC is enabled', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: ['openid'] }) }]]) + queryInterface.sequelize.query.onSecondCall().resolves() + + await up({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true + expect(queryInterface.sequelize.query.calledTwice).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect( + queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify({ authActiveAuthMethods: ['openid'], authOpenIDSubfolderForRedirectURLs: '' }) + } + }) + ).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + }) + + it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: [] }) }]]) + + await up({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] OIDC is not enabled, no action required')).to.be.true + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + }) + + it('should throw an error if server settings cannot be parsed', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: 'invalid json' }]]) + + try { + await up({ context }) + } catch (error) { + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.error.calledWith('[2.17.3 migration] Error parsing server settings:')).to.be.true + expect(error).to.be.instanceOf(Error) + } + }) + + it('should throw an error if server settings are not found', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[]]) + + try { + await up({ context }) + } catch (error) { + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.error.calledWith('[2.17.3 migration] Server settings not found')).to.be.true + expect(error).to.be.instanceOf(Error) + } + }) + }) + + describe('down', () => { + it('should remove authOpenIDSubfolderForRedirectURLs if it exists', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authOpenIDSubfolderForRedirectURLs: '' }) }]]) + queryInterface.sequelize.query.onSecondCall().resolves() + + await down({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true + expect(queryInterface.sequelize.query.calledTwice).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect( + queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify({}) + } + }) + ).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + }) + + it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({}) }]]) + + await down({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + }) + }) +}) From 8c3ba675836c4e5bc916dfe7d60249b02a842468 Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 05:48:04 +0200 Subject: [PATCH 420/539] Fix label order --- client/strings/en-us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 8a91686c10..75069cd337 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -679,8 +679,8 @@ "LabelViewPlayerSettings": "View player settings", "LabelViewQueue": "View player queue", "LabelVolume": "Volume", - "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", "LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:", + "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", "LabelWeekdaysToRun": "Weekdays to run", "LabelXBooks": "{0} books", "LabelXItems": "{0} items", From 9917f2d358c803665cc1bb5750f3f64a1b89577b Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 09:01:03 +0200 Subject: [PATCH 421/539] Change migration to v2.17.4 --- server/migrations/changelog.md | 2 +- ...4-use-subfolder-for-oidc-redirect-uris.js} | 20 ++++++------ ...-subfolder-for-oidc-redirect-uris.test.js} | 32 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) rename server/migrations/{v2.17.3-use-subfolder-for-oidc-redirect-uris.js => v2.17.4-use-subfolder-for-oidc-redirect-uris.js} (82%) rename test/server/migrations/{v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js => v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js} (73%) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8ba4fad00c..67c09d53c4 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -8,4 +8,4 @@ Please add a record of every database migration that you create to this file. Th | v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | | v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | | v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | -| v2.17.3 | v2.17.3-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | +| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | diff --git a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js b/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js similarity index 82% rename from server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js rename to server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js index d03783cddf..03797e35e5 100644 --- a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js +++ b/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js @@ -18,18 +18,18 @@ */ async function up({ context: { queryInterface, logger } }) { // Upwards migration script - logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris') + logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris') const serverSettings = await getServerSettings(queryInterface, logger) if (serverSettings.authActiveAuthMethods?.includes('openid')) { - logger.info('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') + logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') serverSettings.authOpenIDSubfolderForRedirectURLs = '' await updateServerSettings(queryInterface, logger, serverSettings) } else { - logger.info('[2.17.3 migration] OIDC is not enabled, no action required') + logger.info('[2.17.4 migration] OIDC is not enabled, no action required') } - logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris') + logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris') } /** @@ -40,25 +40,25 @@ async function up({ context: { queryInterface, logger } }) { */ async function down({ context: { queryInterface, logger } }) { // Downward migration script - logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ') // Remove the OIDC subfolder option from the server settings const serverSettings = await getServerSettings(queryInterface, logger) if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) { - logger.info('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') + logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') delete serverSettings.authOpenIDSubfolderForRedirectURLs await updateServerSettings(queryInterface, logger, serverSettings) } else { - logger.info('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') + logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') } - logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ') } async function getServerSettings(queryInterface, logger) { const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";') if (!result[0].length) { - logger.error('[2.17.3 migration] Server settings not found') + logger.error('[2.17.4 migration] Server settings not found') throw new Error('Server settings not found') } @@ -66,7 +66,7 @@ async function getServerSettings(queryInterface, logger) { try { serverSettings = JSON.parse(result[0][0].value) } catch (error) { - logger.error('[2.17.3 migration] Error parsing server settings:', error) + logger.error('[2.17.4 migration] Error parsing server settings:', error) throw error } diff --git a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js b/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js similarity index 73% rename from test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js rename to test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js index 157b1ed41a..1662d5f98b 100644 --- a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js +++ b/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js @@ -1,10 +1,10 @@ const { expect } = require('chai') const sinon = require('sinon') -const { up, down } = require('../../../server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris') +const { up, down } = require('../../../server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris') const { Sequelize } = require('sequelize') const Logger = require('../../../server/Logger') -describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { +describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => { let queryInterface, logger, context beforeEach(() => { @@ -27,8 +27,8 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await up({ context }) - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true expect(queryInterface.sequelize.query.calledTwice).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true expect( @@ -38,7 +38,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } }) ).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true }) it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => { @@ -46,11 +46,11 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await up({ context }) - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] OIDC is not enabled, no action required')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] OIDC is not enabled, no action required')).to.be.true expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true }) it('should throw an error if server settings cannot be parsed', async () => { @@ -61,7 +61,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } catch (error) { expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.error.calledWith('[2.17.3 migration] Error parsing server settings:')).to.be.true + expect(logger.error.calledWith('[2.17.4 migration] Error parsing server settings:')).to.be.true expect(error).to.be.instanceOf(Error) } }) @@ -74,7 +74,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } catch (error) { expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.error.calledWith('[2.17.3 migration] Server settings not found')).to.be.true + expect(logger.error.calledWith('[2.17.4 migration] Server settings not found')).to.be.true expect(error).to.be.instanceOf(Error) } }) @@ -87,8 +87,8 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await down({ context }) - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true expect(queryInterface.sequelize.query.calledTwice).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true expect( @@ -98,7 +98,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } }) ).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true }) it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => { @@ -106,11 +106,11 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await down({ context }) - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true }) }) }) From 4b52f31d58216875f9429f1ace2314bb4061d19f Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 30 Nov 2024 15:48:20 -0600 Subject: [PATCH 422/539] Update v2.17.3 migration file to first check if constraints need to be updated, add unit test --- server/migrations/v2.17.3-fk-constraints.js | 116 ++++++--- .../migrations/v2.17.3-fk-constraints.test.js | 230 ++++++++++++++++++ 2 files changed, 308 insertions(+), 38 deletions(-) create mode 100644 test/server/migrations/v2.17.3-fk-constraints.test.js diff --git a/server/migrations/v2.17.3-fk-constraints.js b/server/migrations/v2.17.3-fk-constraints.js index a62307a3e3..5f8a5c9a63 100644 --- a/server/migrations/v2.17.3-fk-constraints.js +++ b/server/migrations/v2.17.3-fk-constraints.js @@ -31,19 +31,28 @@ async function up({ context: { queryInterface, logger } }) { { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } ] - await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints) - logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) { + logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for libraryItems constraints') + } logger.info('[2.17.3 migration] Updating feeds constraints') const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] - await changeConstraints(queryInterface, 'feeds', feedsConstraints) - logger.info('[2.17.3 migration] Finished updating feeds constraints') + if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) { + logger.info('[2.17.3 migration] Finished updating feeds constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for feeds constraints') + } if (await queryInterface.tableExists('mediaItemShares')) { logger.info('[2.17.3 migration] Updating mediaItemShares constraints') const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] - await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints) - logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) { + logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints') + } } else { logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change') } @@ -54,18 +63,27 @@ async function up({ context: { queryInterface, logger } }) { { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, { field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } ] - await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints) - logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) { + logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints') + } logger.info('[2.17.3 migration] Updating playlistMediaItems constraints') const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] - await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints) - logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) { + logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints') + } logger.info('[2.17.3 migration] Updating mediaProgresses constraints') const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] - await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints) - logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) { + logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints') + } await execQuery(`COMMIT;`) } catch (error) { @@ -103,59 +121,75 @@ async function down({ context: { queryInterface, logger } }) { * @property {string} onUpdate - The onUpdate constraint */ -const formatFKsPragmaToSequelizeFK = (fk) => { - let onDelete = fk['on_delete'] - let onUpdate = fk['on_update'] - - if (fk.from === 'userId' || fk.from === 'libraryId' || fk.from === 'deviceId') { - onDelete = 'SET NULL' - onUpdate = 'CASCADE' - } +/** + * @typedef SequelizeFKObj + * @property {{ model: string, key: string }} references + * @property {string} onDelete + * @property {string} onUpdate + */ +/** + * @param {Object} fk - The foreign key object from PRAGMA foreign_key_list + * @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize + */ +const formatFKsPragmaToSequelizeFK = (fk) => { return { references: { model: fk.table, key: fk.to }, - constraints: { - onDelete, - onUpdate - } + onDelete: fk['on_delete'], + onUpdate: fk['on_update'] } } /** - * Extends the Sequelize describeTable function to include the foreign keys constraints in sqlite dbs + * * @param {import('sequelize').QueryInterface} queryInterface - * @param {String} tableName - The table name - * @param {ConstraintUpdateObj[]} constraints - constraints to update + * @param {string} tableName + * @param {ConstraintUpdateObj[]} constraints + * @returns {Promise|null>} */ -async function describeTableWithFKs(queryInterface, tableName, constraints) { +async function getUpdatedForeignKeys(queryInterface, tableName, constraints) { const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) const quotedTableName = queryInterface.quoteIdentifier(tableName) const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`) + let hasUpdates = false const foreignKeysByColName = foreignKeys.reduce((prev, curr) => { const fk = formatFKsPragmaToSequelizeFK(curr) + + const constraint = constraints.find((c) => c.field === curr.from) + if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) { + fk.onDelete = constraint.onDelete + fk.onUpdate = constraint.onUpdate + hasUpdates = true + } + return { ...prev, [curr.from]: fk } }, {}) + return hasUpdates ? foreignKeysByColName : null +} + +/** + * Extends the Sequelize describeTable function to include the updated foreign key constraints + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {String} tableName + * @param {Record} updatedForeignKeys + */ +async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) { const tableDescription = await queryInterface.describeTable(tableName) const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => { let extendedAttributes = attributes - if (foreignKeysByColName[col]) { - // Use the constraints from the constraints array if they exist, otherwise use the existing constraints - const onDelete = constraints.find((c) => c.field === col)?.onDelete || foreignKeysByColName[col].constraints.onDelete - const onUpdate = constraints.find((c) => c.field === col)?.onUpdate || foreignKeysByColName[col].constraints.onUpdate - + if (updatedForeignKeys[col]) { extendedAttributes = { ...extendedAttributes, - references: foreignKeysByColName[col].references, - onDelete, - onUpdate + ...updatedForeignKeys[col] } } return { ...prev, [col]: extendedAttributes } @@ -171,8 +205,14 @@ async function describeTableWithFKs(queryInterface, tableName, constraints) { * @param {import('sequelize').QueryInterface} queryInterface * @param {string} tableName * @param {ConstraintUpdateObj[]} constraints + * @returns {Promise} - Return false if no changes are needed, true otherwise */ async function changeConstraints(queryInterface, tableName, constraints) { + const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints) + if (!updatedForeignKeys) { + return false + } + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) const quotedTableName = queryInterface.quoteIdentifier(tableName) @@ -180,7 +220,7 @@ async function changeConstraints(queryInterface, tableName, constraints) { const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName) try { - const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, constraints) + const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks) @@ -210,7 +250,7 @@ async function changeConstraints(queryInterface, tableName, constraints) { return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`) } - return Promise.resolve() + return true } catch (error) { return Promise.reject(error) } diff --git a/test/server/migrations/v2.17.3-fk-constraints.test.js b/test/server/migrations/v2.17.3-fk-constraints.test.js new file mode 100644 index 0000000000..33be43ce83 --- /dev/null +++ b/test/server/migrations/v2.17.3-fk-constraints.test.js @@ -0,0 +1,230 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up } = require('../../../server/migrations/v2.17.3-fk-constraints') +const { Sequelize, QueryInterface } = require('sequelize') +const Logger = require('../../../server/Logger') + +describe('migration-v2.17.3-fk-constraints', () => { + let sequelize + /** @type {QueryInterface} */ + let queryInterface + let loggerInfoStub + + beforeEach(() => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + beforeEach(async () => { + // Create associated tables: Users, libraries, libraryFolders, playlists, devices + await queryInterface.sequelize.query('CREATE TABLE `users` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `libraries` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `libraryFolders` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `playlists` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `devices` (`id` UUID PRIMARY KEY);') + }) + + afterEach(async () => { + await queryInterface.dropAllTables() + }) + + it('should fix table foreign key constraints', async () => { + // Create tables with missing foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses + await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID UNIQUE PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`), `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`), `deviceId` UUID REFERENCES `devices` (`id`), `libraryId` UUID REFERENCES `libraries` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID UNIQUE PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + + // + // Validate that foreign key constraints are missing + // + let libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`) + expect(libraryItemsForeignKeys).to.have.deep.members([ + { id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' } + ]) + + let feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`) + expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`) + expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`) + expect(playbackSessionForeignKeys).to.deep.equal([ + { id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' } + ]) + + let playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`) + expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`) + expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + // + // Insert test data into tables + // + await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }]) + + await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + + // + // Query data before migration + // + const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;') + const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + + // + // Run migration + // + await up({ context: { queryInterface, logger: Logger } }) + + // + // Validate that foreign key constraints are updated + // + libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`) + expect(libraryItemsForeignKeys).to.have.deep.members([ + { id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' } + ]) + + feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`) + expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }]) + + mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`) + expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }]) + + playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`) + expect(playbackSessionForeignKeys).to.deep.equal([ + { id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' } + ]) + + playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`) + expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }]) + + mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`) + expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }]) + + // + // Validate that data is not changed + // + const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + expect(libraryItemsAfter).to.deep.equal(libraryItems) + + const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;') + expect(feedsAfter).to.deep.equal(feeds) + + const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares) + + const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + expect(playbackSessionsAfter).to.deep.equal(playbackSessions) + + const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems) + + const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + expect(mediaProgressesAfter).to.deep.equal(mediaProgresses) + }) + + it('should keep correct table foreign key constraints', async () => { + // Create tables with correct foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses + await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `deviceId` UUID REFERENCES `devices` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);') + + // + // Insert test data into tables + // + await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }]) + + await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + + // + // Query data before migration + // + const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;') + const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(14) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints'))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.17.3 migration] Updating libraryItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.17.3 migration] No changes needed for libraryItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.17.3 migration] Updating feeds constraints'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.17.3 migration] No changes needed for feeds constraints'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.17.3 migration] Updating mediaItemShares constraints'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaItemShares constraints'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.17.3 migration] Updating playbackSessions constraints'))).to.be.true + expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.17.3 migration] No changes needed for playbackSessions constraints'))).to.be.true + expect(loggerInfoStub.getCall(9).calledWith(sinon.match('[2.17.3 migration] Updating playlistMediaItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(10).calledWith(sinon.match('[2.17.3 migration] No changes needed for playlistMediaItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(11).calledWith(sinon.match('[2.17.3 migration] Updating mediaProgresses constraints'))).to.be.true + expect(loggerInfoStub.getCall(12).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaProgresses constraints'))).to.be.true + expect(loggerInfoStub.getCall(13).calledWith(sinon.match('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints'))).to.be.true + + // + // Validate that data is not changed + // + const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + expect(libraryItemsAfter).to.deep.equal(libraryItems) + + const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;') + expect(feedsAfter).to.deep.equal(feeds) + + const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares) + + const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + expect(playbackSessionsAfter).to.deep.equal(playbackSessions) + + const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems) + + const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + expect(mediaProgressesAfter).to.deep.equal(mediaProgresses) + }) + }) +}) From 60ba0163af004d590efeb0b0f3fc4c513b973662 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:03:08 +0000 Subject: [PATCH 423/539] Translated using Weblate (German) Currently translated at 99.9% (1071 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/de.json b/client/strings/de.json index 6dff93381b..030f8f1b3b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird", "LabelUpdatedAt": "Aktualisiert am", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", + "LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen", "LabelUploaderDropFiles": "Dateien löschen", "LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie", "LabelUseAdvancedOptions": "Nutze Erweiterte Optionen", From d2c28fc69cf9a52ee41bcc9ffea05500382cf4f4 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 22 Nov 2024 09:14:11 +0000 Subject: [PATCH 424/539] Translated using Weblate (Spanish) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/es.json b/client/strings/es.json index b45d253457..76a62c1618 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados", "LabelUpdatedAt": "Actualizado En", "LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas", + "LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos", "LabelUploaderDropFiles": "Suelte los Archivos", "LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente", "LabelUseAdvancedOptions": "Usar opciones avanzadas", From 0449fb5ef92e7093929cef3951390b907ef80c18 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Sat, 23 Nov 2024 11:40:05 +0000 Subject: [PATCH 425/539] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 81cd13f4b7..448bbf4c86 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення", "LabelUpdatedAt": "Оновлення", "LabelUploaderDragAndDrop": "Перетягніть файли або теки", + "LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли", "LabelUploaderDropFiles": "Перетягніть файли", "LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію", "LabelUseAdvancedOptions": "Використовувати розширені налаштування", From 7278ad4ee759661091c1b650ab7cac38a3742184 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 22 Nov 2024 06:05:51 +0000 Subject: [PATCH 426/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index 366c8479b3..02c1fb132c 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje", "LabelUpdatedAt": "Posodobljeno ob", "LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape", + "LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke", "LabelUploaderDropFiles": "Spusti datoteke", "LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo", "LabelUseAdvancedOptions": "Uporabi napredne možnosti", From 293e53029766c3324421816cf00954bf073da895 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sun, 24 Nov 2024 08:57:39 +0000 Subject: [PATCH 427/539] Translated using Weblate (Croatian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 502973c441..a7f2562b79 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju", "LabelUpdatedAt": "Ažurirano", "LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape", + "LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke", "LabelUploaderDropFiles": "Ispusti datoteke", "LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal", "LabelUseAdvancedOptions": "Koristi se naprednim opcijama", From ddcbfd450013c3a9f2310579a3543f34ad811a37 Mon Sep 17 00:00:00 2001 From: Soaibuzzaman Date: Mon, 25 Nov 2024 08:40:51 +0000 Subject: [PATCH 428/539] Translated using Weblate (Bengali) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/ --- client/strings/bn.json | 82 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/strings/bn.json b/client/strings/bn.json index b705a802e3..16f8a4478f 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -66,6 +66,7 @@ "ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন", "ButtonQueueAddItem": "সারিতে যোগ করুন", "ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন", + "ButtonQuickEmbed": "দ্রুত এম্বেড করুন", "ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন", "ButtonQuickMatch": "দ্রুত ম্যাচ", "ButtonReScan": "পুনরায় স্ক্যান", @@ -162,6 +163,7 @@ "HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন", "HeaderNotifications": "বিজ্ঞপ্তি", "HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ", + "HeaderOpenListeningSessions": "শোনার সেশন খুলুন", "HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন", "HeaderOtherFiles": "অন্যান্য ফাইল", "HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ", @@ -179,6 +181,7 @@ "HeaderRemoveEpisodes": "{0}টি পর্ব সরান", "HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি", "HeaderSchedule": "সময়সূচী", + "HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন", "HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী", "HeaderSession": "সেশন", "HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন", @@ -224,7 +227,11 @@ "LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী", "LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী", "LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে", + "LabelApiToken": "API টোকেন", "LabelAppend": "সংযোজন", + "LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)", + "LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)", + "LabelAudioCodec": "অডিও কোডেক", "LabelAuthor": "লেখক", "LabelAuthorFirstLast": "লেখক (প্রথম শেষ)", "LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)", @@ -237,6 +244,7 @@ "LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন", "LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন", "LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান", + "LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ", "LabelBackupLocation": "ব্যাকআপ অবস্থান", "LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন", "LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত", @@ -245,15 +253,18 @@ "LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন", "LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।", "LabelBitrate": "বিটরেট", + "LabelBonus": "উপরিলাভ", "LabelBooks": "বইগুলো", "LabelButtonText": "ঘর পাঠ্য", "LabelByAuthor": "দ্বারা {0}", "LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন", "LabelChannels": "চ্যানেল", + "LabelChapterCount": "{0} অধ্যায়", "LabelChapterTitle": "অধ্যায়ের শিরোনাম", "LabelChapters": "অধ্যায়", "LabelChaptersFound": "অধ্যায় পাওয়া গেছে", "LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন", + "LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন", "LabelClosePlayer": "প্লেয়ার বন্ধ করুন", "LabelCodec": "কোডেক", "LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন", @@ -303,12 +314,25 @@ "LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা", "LabelEmbeddedCover": "এম্বেডেড কভার", "LabelEnable": "সক্ষম করুন", + "LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:", + "LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।", + "LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।", + "LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:", + "LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।", + "LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।", + "LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।", + "LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।", + "LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।", "LabelEnd": "সমাপ্ত", "LabelEndOfChapter": "অধ্যায়ের সমাপ্তি", "LabelEpisode": "পর্ব", + "LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি", + "LabelEpisodeNumber": "পর্ব #{0}", "LabelEpisodeTitle": "পর্বের শিরোনাম", "LabelEpisodeType": "পর্বের ধরন", + "LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL", "LabelEpisodes": "পর্বগুলো", + "LabelEpisodic": "প্রাসঙ্গিক", "LabelExample": "উদাহরণ", "LabelExpandSeries": "সিরিজ প্রসারিত করুন", "LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন", @@ -336,6 +360,7 @@ "LabelFontScale": "ফন্ট স্কেল", "LabelFontStrikethrough": "অবচ্ছেদন রেখা", "LabelFormat": "ফরম্যাট", + "LabelFull": "পূর্ণ", "LabelGenre": "ঘরানা", "LabelGenres": "ঘরানাগুলো", "LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন", @@ -391,6 +416,10 @@ "LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার", "LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন", "LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে", + "LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।", + "LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে", + "LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে", + "LabelMaxEpisodesToKeepHelp": "০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।", "LabelMediaPlayer": "মিডিয়া প্লেয়ার", "LabelMediaType": "মিডিয়ার ধরন", "LabelMetaTag": "মেটা ট্যাগ", @@ -436,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত গ্রুপ হিসাবে উল্লেখ করা হয়। কনফিগার করা থাকলে, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।", "LabelOpenRSSFeed": "আরএসএস ফিড খুলুন", "LabelOverwrite": "পুনঃলিখিত", + "LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা", "LabelPassword": "পাসওয়ার্ড", "LabelPath": "পথ", "LabelPermanent": "স্থায়ী", "LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে", + "LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন", "LabelPermissionsDelete": "মুছে দিতে পারবে", "LabelPermissionsDownload": "ডাউনলোড করতে পারবে", "LabelPermissionsUpdate": "আপডেট করতে পারবে", @@ -465,6 +496,8 @@ "LabelPubDate": "প্রকাশের তারিখ", "LabelPublishYear": "প্রকাশের বছর", "LabelPublishedDate": "প্রকাশিত {0}", + "LabelPublishedDecade": "প্রকাশনার দশক", + "LabelPublishedDecades": "প্রকাশনার দশকগুলো", "LabelPublisher": "প্রকাশক", "LabelPublishers": "প্রকাশকরা", "LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল", @@ -484,21 +517,28 @@ "LabelRedo": "পুনরায় করুন", "LabelRegion": "অঞ্চল", "LabelReleaseDate": "উন্মোচনের তারিখ", + "LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান", + "LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান", "LabelRemoveCover": "কভার সরান", + "LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান", + "LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।", "LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি", "LabelSearchTerm": "অনুসন্ধান শব্দ", "LabelSearchTitle": "অনুসন্ধান শিরোনাম", "LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN", "LabelSeason": "সেশন", + "LabelSeasonNumber": "মরসুম #{0}", "LabelSelectAll": "সব নির্বাচন করুন", "LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন", "LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন", "LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন", "LabelSendEbookToDevice": "ই-বই পাঠান...", "LabelSequence": "ক্রম", + "LabelSerial": "ধারাবাহিক", "LabelSeries": "সিরিজ", "LabelSeriesName": "সিরিজের নাম", "LabelSeriesProgress": "সিরিজের অগ্রগতি", + "LabelServerLogLevel": "সার্ভার লগ লেভেল", "LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})", "LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন", "LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন", @@ -523,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।", "LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম", + "LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।", "LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন", @@ -587,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} মিনিট", "LabelTimeDurationXSeconds": "{0} সেকেন্ড", "LabelTimeInMinutes": "মিনিটে সময়", + "LabelTimeLeft": "{0} বাকি", "LabelTimeListened": "সময় শোনা হয়েছে", "LabelTimeListenedToday": "আজ শোনার সময়", "LabelTimeRemaining": "{0}টি অবশিষ্ট", @@ -594,6 +638,7 @@ "LabelTitle": "শিরোনাম", "LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন", "LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।", + "LabelToolsM4bEncoder": "M4B এনকোডার", "LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন", "LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।", "LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন", @@ -606,6 +651,7 @@ "LabelTracksMultiTrack": "মাল্টি-ট্র্যাক", "LabelTracksNone": "কোন ট্র্যাক নেই", "LabelTracksSingleTrack": "একক-ট্র্যাক", + "LabelTrailer": "আনুগমিক", "LabelType": "টাইপ", "LabelUnabridged": "অসংলগ্ন", "LabelUndo": "পূর্বাবস্থা", @@ -617,10 +663,13 @@ "LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন", "LabelUpdatedAt": "আপডেট করা হয়েছে", "LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন", + "LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন", "LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন", "LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন", + "LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন", "LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন", "LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন", + "LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন", "LabelUser": "ব্যবহারকারী", "LabelUsername": "ব্যবহারকারীর নাম", "LabelValue": "মান", @@ -667,6 +716,7 @@ "MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?", "MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?", "MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?", + "MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?", "MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?", "MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?", "MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?", @@ -678,6 +728,7 @@ "MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক /metadata/cache-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।

আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?", "MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক /metadata/cache/items-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।
আপনি কি নিশ্চিত?", "MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে।

আপনি কি চালিয়ে যেতে চান?", + "MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?", "MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?", "MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?", "MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?", @@ -685,6 +736,7 @@ "MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?", "MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?", "MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?", + "MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?", "MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?", "MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?", "MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?", @@ -700,6 +752,7 @@ "MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন", "MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!", "MessageEmbedFinished": "এম্বেড করা শেষ!", + "MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)", "MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ", "MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।", "MessageFeedURLWillBe": "ফিড URL হবে {0}", @@ -744,6 +797,7 @@ "MessageNoLogs": "কোনও লগ নেই", "MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই", "MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই", + "MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই", "MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি", "MessageNoResults": "কোন ফলাফল নেই", "MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই", @@ -760,6 +814,10 @@ "MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন", "MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।", "MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই", + "MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন", + "MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে", + "MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)", + "MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব", "MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।", "MessageRemoveChapter": "অধ্যায় সরান", "MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান", @@ -802,6 +860,9 @@ "MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান", "MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ", "MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে", + "MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে", + "MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল ট্যাগ পাওয়া যায়নি বা একটি ট্যাগ পাওয়া যায়নি", + "MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি", "MessageTaskScanItemsAdded": "{0}টি করা হয়েছে", "MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত", "MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে", @@ -826,6 +887,10 @@ "NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।", "NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।", "NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।", + "NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে", + "NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে", + "NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে", + "NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট", "PlaceholderNewCollection": "নতুন সংগ্রহের নাম", "PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ", "PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম", @@ -851,6 +916,7 @@ "StatsYearInReview": "বাৎসরিক পর্যালোচনা", "ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে", "ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে", + "ToastAsinRequired": "ASIN প্রয়োজন", "ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে", "ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি", "ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে", @@ -870,6 +936,8 @@ "ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে", "ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে", "ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে", + "ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!", + "ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!", "ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে", "ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য", "ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ", @@ -881,6 +949,7 @@ "ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে", "ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে", "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", + "ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে", "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", "ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে", "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে", @@ -898,11 +967,14 @@ "ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে", "ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে", "ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে", + "ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে", "ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না", "ToastFailedToLoadData": "ডেটা লোড করা যায়নি", + "ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে", "ToastFailedToShare": "শেয়ার করতে ব্যর্থ", "ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে", "ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল", + "ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব", "ToastInvalidUrl": "অকার্যকর ইউআরএল", "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", "ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ", @@ -920,14 +992,22 @@ "ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ", "ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে", "ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে", + "ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে", + "ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল", + "ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে", + "ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে", + "ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে", + "ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে", "ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক", "ToastNameRequired": "নাম আবশ্যক", + "ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে", "ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"", "ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে", "ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে", "ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে", "ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে", "ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন", + "ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি", "ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই", "ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ", "ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ", @@ -946,6 +1026,7 @@ "ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে", "ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি", "ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই", + "ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন", "ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে", "ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে", "ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক", @@ -972,6 +1053,7 @@ "ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে", "ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ", "ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে", + "ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz", "ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে", "ToastSlugRequired": "স্লাগ আবশ্যক", "ToastSocketConnected": "সকেট সংযুক্ত", From a5457d7e22238432f3cfc4064cb6bfec670f6777 Mon Sep 17 00:00:00 2001 From: Pierrick Guillaume Date: Mon, 25 Nov 2024 05:04:14 +0000 Subject: [PATCH 429/539] Translated using Weblate (French) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index a1f5c2c86a..28cdd34217 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée", "LabelUpdatedAt": "Mis à jour à", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", + "LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série", "LabelUseAdvancedOptions": "Utiliser les options avancées", From 1ff1ba66fdd57d8a109ac240fb988be5186ad887 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 25 Nov 2024 19:38:25 +0000 Subject: [PATCH 430/539] Translated using Weblate (Russian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/ru.json b/client/strings/ru.json index b0743af630..2ec2fbd133 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUpdatedAt": "Обновлено в", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", + "LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов", "LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", "LabelUseAdvancedOptions": "Используйте расширенные опции", From 31e302ea599e43a095cc76f8e219330b1b66eda2 Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 29 Nov 2024 13:49:11 +0000 Subject: [PATCH 431/539] Translated using Weblate (French) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 28cdd34217..42dfefc577 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -870,10 +870,10 @@ "MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »", "MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »", "MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture", - "MessageThinking": "Je cherche…", + "MessageThinking": "À la recherche de…", "MessageUploaderItemFailed": "Échec du téléversement", "MessageUploaderItemSuccess": "Téléversement effectué !", - "MessageUploading": "Téléversement…", + "MessageUploading": "Téléchargement…", "MessageValidCronExpression": "Expression cron valide", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", From 468a5478642baec96abb3110c5bc1892eca893b7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 30 Nov 2024 16:26:48 -0600 Subject: [PATCH 432/539] Version bump v2.17.3 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index b8b17f3efb..588ad79dd8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 924220e0f0..c1a43e5257 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 3f9f7a44ca..062fb03221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 8cbbb029f3..db63261b1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From c496db7c95752407164aa55d4dc6f35e207db9f1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 09:51:26 -0600 Subject: [PATCH 433/539] Fix:Remove authors with no books when a books is removed #3668 - Handles bulk delete, single delete, deleting library folder, and removing items with issues - Also handles bulk editing and removing authors --- server/controllers/LibraryController.js | 66 +++++++++- server/controllers/LibraryItemController.js | 127 +++++++++++++++----- server/routers/ApiRouter.js | 67 +++-------- 3 files changed, 177 insertions(+), 83 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 84d6193d5e..fc15488dc8 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -400,19 +400,48 @@ class LibraryController { model: Database.podcastEpisodeModel, attributes: ['id'] } + }, + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookAuthorModel, + attributes: ['authorId'] + }, + { + model: Database.bookSeriesModel, + attributes: ['seriesId'] + } + ] } ] }) Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`) + const seriesIds = [] + const authorIds = [] for (const libraryItem of libraryItemsInFolder) { let mediaItemIds = [] if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) + if (libraryItem.media.bookAuthors.length) { + authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId)) + } + if (libraryItem.media.bookSeries.length) { + seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId)) + } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) } // Remove folder @@ -501,7 +530,7 @@ class LibraryController { mediaItemIds.push(libraryItem.mediaId) } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) } // Set PlaybackSessions libraryId to null @@ -580,6 +609,8 @@ class LibraryController { * DELETE: /api/libraries/:id/issues * Remove all library items missing or invalid * + * @this {import('../routers/ApiRouter')} + * * @param {LibraryControllerRequest} req * @param {Response} res */ @@ -605,6 +636,20 @@ class LibraryController { model: Database.podcastEpisodeModel, attributes: ['id'] } + }, + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookAuthorModel, + attributes: ['authorId'] + }, + { + model: Database.bookSeriesModel, + attributes: ['seriesId'] + } + ] } ] }) @@ -615,15 +660,30 @@ class LibraryController { } Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) + const authorIds = [] + const seriesIds = [] for (const libraryItem of libraryItemsWithIssues) { let mediaItemIds = [] if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) + if (libraryItem.media.bookAuthors.length) { + authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId)) + } + if (libraryItem.media.bookSeries.length) { + seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId)) + } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) } // Set numIssues to 0 for library filter data diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 64069ac574..92bc383315 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -96,6 +96,8 @@ class LibraryItemController { * Optional query params: * ?hard=1 * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -103,14 +105,36 @@ class LibraryItemController { const hardDelete = req.query.hard == 1 // Delete from file system const libraryItemPath = req.libraryItem.path - const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id] - await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds) + const mediaItemIds = [] + const authorIds = [] + const seriesIds = [] + if (req.libraryItem.isPodcast) { + mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id)) + } else { + mediaItemIds.push(req.libraryItem.media.id) + if (req.libraryItem.media.metadata.authors?.length) { + authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id)) + } + if (req.libraryItem.media.metadata.series?.length) { + seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id)) + } + } + + await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) }) } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) + } + await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId) res.sendStatus(200) } @@ -212,15 +236,6 @@ class LibraryItemController { if (hasUpdates) { libraryItem.updatedAt = Date.now() - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries( - libraryItem.media.id, - seriesRemoved.map((se) => se.id) - ) - } - if (isPodcastAutoDownloadUpdated) { this.cronManager.checkUpdatePodcastCron(libraryItem) } @@ -232,10 +247,12 @@ class LibraryItemController { if (authorsRemoved.length) { // Check remove empty authors Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) - await this.checkRemoveAuthorsWithNoBooks( - libraryItem.libraryId, - authorsRemoved.map((au) => au.id) - ) + await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id)) + } + if (seriesRemoved.length) { + // Check remove empty series + Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`) + await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id)) } } res.json({ @@ -450,6 +467,8 @@ class LibraryItemController { * Optional query params: * ?hard=1 * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -477,14 +496,33 @@ class LibraryItemController { for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) - const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + const mediaItemIds = [] + const seriesIds = [] + const authorIds = [] + if (libraryItem.isPodcast) { + mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id)) + } else { + mediaItemIds.push(libraryItem.media.id) + if (libraryItem.media.metadata.series?.length) { + seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id)) + } + if (libraryItem.media.metadata.authors?.length) { + authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id)) + } + } + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) }) } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) + } + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } } await Database.resetLibraryIssuesFilterData(libraryId) @@ -494,6 +532,8 @@ class LibraryItemController { /** * POST: /api/items/batch/update * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -503,39 +543,62 @@ class LibraryItemController { return res.sendStatus(500) } + // Ensure that each update payload has a unique library item id + const libraryItemIds = [...new Set(updatePayloads.map((up) => up.id))] + if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) { + Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`) + return res.sendStatus(400) + } + + // Get all library items to update + const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ + id: libraryItemIds + }) + if (updatePayloads.length !== libraryItems.length) { + Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`) + return res.sendStatus(404) + } + let itemsUpdated = 0 + const seriesIdsRemoved = [] + const authorIdsRemoved = [] + for (const updatePayload of updatePayloads) { const mediaPayload = updatePayload.mediaPayload - const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id) - if (!libraryItem) return null + const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) - let seriesRemoved = [] - if (libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id) - seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + if (libraryItem.isBook) { + if (Array.isArray(mediaPayload.metadata?.series)) { + const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) + const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id)) + } + if (Array.isArray(mediaPayload.metadata?.authors)) { + const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) + const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) + } } if (libraryItem.media.update(mediaPayload)) { Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries( - libraryItem.media.id, - seriesRemoved.map((se) => se.id) - ) - } - await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) itemsUpdated++ } } + if (seriesIdsRemoved.length) { + await this.checkRemoveEmptySeries(seriesIdsRemoved) + } + if (authorIdsRemoved.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved) + } + res.json({ success: true, updates: itemsUpdated diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 7f21c3ac51..0657b38950 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -348,11 +348,10 @@ class ApiRouter { // /** * Remove library item and associated entities - * @param {string} mediaType * @param {string} libraryItemId * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId */ - async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) { + async handleDeleteLibraryItem(libraryItemId, mediaItemIds) { const numProgressRemoved = await Database.mediaProgressModel.destroy({ where: { mediaItemId: mediaItemIds @@ -362,29 +361,6 @@ class ApiRouter { Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`) } - // TODO: Remove open sessions for library item - - // Remove series if empty - if (mediaType === 'book') { - // TODO: update filter data - const bookSeries = await Database.bookSeriesModel.findAll({ - where: { - bookId: mediaItemIds[0] - }, - include: { - model: Database.seriesModel, - include: { - model: Database.bookModel - } - } - }) - for (const bs of bookSeries) { - if (bs.series.books.length === 1) { - await this.removeEmptySeries(bs.series) - } - } - } - // remove item from playlists const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds) for (const playlist of playlistsWithItem) { @@ -423,6 +399,7 @@ class ApiRouter { // purge cover cache await CacheManager.purgeCoverCache(libraryItemId) + // Remove metadata file if in /metadata/items dir const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) if (await fs.pathExists(itemMetadataPath)) { Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) @@ -437,32 +414,27 @@ class ApiRouter { } /** - * Used when a series is removed from a book - * Series is removed if it only has 1 book + * After deleting book(s), remove empty series * - * @param {string} bookId * @param {string[]} seriesIds */ - async checkRemoveEmptySeries(bookId, seriesIds) { + async checkRemoveEmptySeries(seriesIds) { if (!seriesIds?.length) return - const bookSeries = await Database.bookSeriesModel.findAll({ + const series = await Database.seriesModel.findAll({ where: { - bookId, - seriesId: seriesIds + id: seriesIds }, - include: [ - { - model: Database.seriesModel, - include: { - model: Database.bookModel - } - } - ] + attributes: ['id', 'name', 'libraryId'], + include: { + model: Database.bookModel, + attributes: ['id'] + } }) - for (const bs of bookSeries) { - if (bs.series.books.length === 1) { - await this.removeEmptySeries(bs.series) + + for (const s of series) { + if (!s.books.length) { + await this.removeEmptySeries(s) } } } @@ -471,11 +443,10 @@ class ApiRouter { * Remove authors with no books and unset asin, description and imagePath * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged) * - * @param {string} libraryId * @param {string[]} authorIds * @returns {Promise} */ - async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) { + async checkRemoveAuthorsWithNoBooks(authorIds) { if (!authorIds?.length) return const bookAuthorsToRemove = ( @@ -495,10 +466,10 @@ class ApiRouter { }, sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) ], - attributes: ['id', 'name'], + attributes: ['id', 'name', 'libraryId'], raw: true }) - ).map((au) => ({ id: au.id, name: au.name })) + ).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId })) if (bookAuthorsToRemove.length) { await Database.authorModel.destroy({ @@ -506,7 +477,7 @@ class ApiRouter { id: bookAuthorsToRemove.map((au) => au.id) } }) - bookAuthorsToRemove.forEach(({ id, name }) => { + bookAuthorsToRemove.forEach(({ id, name, libraryId }) => { Database.removeAuthorFromFilterData(libraryId, id) // TODO: Clients were expecting full author in payload but its unnecessary SocketAuthority.emitter('author_removed', { id, libraryId }) From 2b5484243b649ed2dac3c996eb1d9b8e907b4c4a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 12:44:21 -0600 Subject: [PATCH 434/539] Add LibraryItemController test for delete/batchDelete/updateMedia endpoint functions to correctly remove authors & series with no books --- server/managers/CacheManager.js | 1 + server/objects/LibraryItem.js | 2 +- server/routers/ApiRouter.js | 10 +- .../controllers/LibraryItemController.test.js | 202 ++++++++++++++++++ 4 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 test/server/controllers/LibraryItemController.test.js diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index f03756918a..b44b65de32 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -86,6 +86,7 @@ class CacheManager { } async purgeEntityCache(entityId, cachePath) { + if (!entityId || !cachePath) return [] return Promise.all( (await fs.readdir(cachePath)).reduce((promises, file) => { if (file.startsWith(entityId)) { diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 0259ee4c92..84a37897a7 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -262,7 +262,7 @@ class LibraryItem { * @returns {Promise} null if not saved */ async saveMetadata() { - if (this.isSavingMetadata) return null + if (this.isSavingMetadata || !global.MetadataPath) return null this.isSavingMetadata = true diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 0657b38950..a92796e8e9 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -400,10 +400,12 @@ class ApiRouter { await CacheManager.purgeCoverCache(libraryItemId) // Remove metadata file if in /metadata/items dir - const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) - if (await fs.pathExists(itemMetadataPath)) { - Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) - await fs.remove(itemMetadataPath) + if (global.MetadataPath) { + const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) + if (await fs.pathExists(itemMetadataPath)) { + Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) + await fs.remove(itemMetadataPath) + } } await Database.libraryItemModel.removeById(libraryItemId) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js new file mode 100644 index 0000000000..3e7c58b25a --- /dev/null +++ b/test/server/controllers/LibraryItemController.test.js @@ -0,0 +1,202 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const ApiRouter = require('../../../server/routers/ApiRouter') +const LibraryItemController = require('../../../server/controllers/LibraryItemController') +const ApiCacheManager = require('../../../server/managers/ApiCacheManager') +const RssFeedManager = require('../../../server/managers/RssFeedManager') +const Logger = require('../../../server/Logger') + +describe('LibraryItemController', () => { + /** @type {ApiRouter} */ + let apiRouter + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + apiRouter = new ApiRouter({ + apiCacheManager: new ApiCacheManager(), + rssFeedManager: new RssFeedManager() + }) + + sinon.stub(Logger, 'info') + }) + + afterEach(async () => { + sinon.restore() + + // Clear all tables + await Database.sequelize.sync({ force: true }) + }) + + describe('checkRemoveAuthorsAndSeries', () => { + let libraryItem1Id + let libraryItem2Id + let author1Id + let author2Id + let author3Id + let series1Id + let series2Id + + beforeEach(async () => { + const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id }) + + const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const newLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem1Id = newLibraryItem.id + + const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const newLibraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook2.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem2Id = newLibraryItem2.id + + const newAuthor = await Database.authorModel.create({ name: 'Test Author', libraryId: newLibrary.id }) + author1Id = newAuthor.id + const newAuthor2 = await Database.authorModel.create({ name: 'Test Author 2', libraryId: newLibrary.id }) + author2Id = newAuthor2.id + const newAuthor3 = await Database.authorModel.create({ name: 'Test Author 3', imagePath: '/fake/path/author.png', libraryId: newLibrary.id }) + author3Id = newAuthor3.id + + // Book 1 has Author 1, Author 2 and Author 3 + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor.id }) + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor2.id }) + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor3.id }) + + // Book 2 has Author 2 + await Database.bookAuthorModel.create({ bookId: newBook2.id, authorId: newAuthor2.id }) + + const newSeries = await Database.seriesModel.create({ name: 'Test Series', libraryId: newLibrary.id }) + series1Id = newSeries.id + const newSeries2 = await Database.seriesModel.create({ name: 'Test Series 2', libraryId: newLibrary.id }) + series2Id = newSeries2.id + + // Book 1 is in Series 1 and Series 2 + await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries.id }) + await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries2.id }) + + // Book 2 is in Series 2 + await Database.bookSeriesModel.create({ bookId: newBook2.id, seriesId: newSeries2.id }) + }) + + it('should remove authors and series with no books on library item delete', async () => { + const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + + const fakeReq = { + query: {}, + libraryItem: oldLibraryItem + } + const fakeRes = { + sendStatus: sinon.spy() + } + await LibraryItemController.delete.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + + it('should remove authors and series with no books on library item batch delete', async () => { + // Batch delete library item 1 + const fakeReq = { + query: {}, + user: { + canDelete: true + }, + body: { + libraryItemIds: [libraryItem1Id] + } + } + const fakeRes = { + sendStatus: sinon.spy() + } + await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + + it('should remove authors and series with no books on library item update media', async () => { + const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + + // Update library item 1 remove all authors and series + const fakeReq = { + query: {}, + body: { + metadata: { + authors: [], + series: [] + } + }, + libraryItem: oldLibraryItem + } + const fakeRes = { + json: sinon.spy() + } + await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + }) +}) From 0dedb09a07c3286d2383892520c107dfb06603c2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 12:49:39 -0600 Subject: [PATCH 435/539] Update:batchUpdate endpoint validate req.body is an array of objects --- server/controllers/LibraryItemController.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 92bc383315..5aaacee0de 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -539,12 +539,13 @@ class LibraryItemController { */ async batchUpdate(req, res) { const updatePayloads = req.body - if (!updatePayloads?.length) { - return res.sendStatus(500) + if (!Array.isArray(updatePayloads) || !updatePayloads.length) { + Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`) + return res.sendStatus(400) } // Ensure that each update payload has a unique library item id - const libraryItemIds = [...new Set(updatePayloads.map((up) => up.id))] + const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))] if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) { Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`) return res.sendStatus(400) From a03146e09c7ba04311a0e8e14765809cce151630 Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 18:10:44 -0800 Subject: [PATCH 436/539] Support additional disc folder names --- server/utils/scandir.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index ff21e814f2..27cfe003a8 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -96,7 +96,7 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) { // This is the last directory, create group itemGroup[_path] = [Path.basename(path)] return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { + } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] return From cc89db059bdd8ed3595f4846a78d5f843f2cdefa Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 18:41:38 -0800 Subject: [PATCH 437/539] Fix second instance of regex --- server/utils/scandir.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 27cfe003a8..028a1022db 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -179,7 +179,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly // This is the last directory, create group libraryItemGroup[_path] = [item.name] return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { + } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)] return From 605bd73c11aa2b79552a1da26f6c29ff904b899a Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 23:57:47 -0800 Subject: [PATCH 438/539] Fix third instance of regex --- server/scanner/AudioFileScanner.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index 3c364c1066..6c808aaa1c 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -133,8 +133,8 @@ class AudioFileScanner { // Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3 const pathdir = Path.dirname(path).split('/').pop() - if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) { - const discFromFolder = Number(pathdir.replace(/cd/i, '')) + if (pathdir && /^(cd|dis[ck])\s*\d{1,3}$/i.test(pathdir)) { + const discFromFolder = Number(pathdir.replace(/^(cd|dis[ck])\s*/i, '')) if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder } From 84803cef82226ca3382dc9a76cc5a42292720c76 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Dec 2024 17:23:25 -0600 Subject: [PATCH 439/539] Fix:Load year in review stats for playback sessions with null mediaMetadata --- server/utils/queries/adminStats.js | 57 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/server/utils/queries/adminStats.js b/server/utils/queries/adminStats.js index 0c490de42d..9d7f572a30 100644 --- a/server/utils/queries/adminStats.js +++ b/server/utils/queries/adminStats.js @@ -5,7 +5,7 @@ const fsExtra = require('../../libs/fsExtra') module.exports = { /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -22,7 +22,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -39,7 +39,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -63,7 +63,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY */ async getStatsForYear(year) { @@ -75,7 +75,7 @@ module.exports = { for (const book of booksAdded) { // Grab first 25 that have a cover - if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) { + if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) { booksWithCovers.push(book.libraryItem.id) } if (book.duration && !isNaN(book.duration)) { @@ -95,45 +95,54 @@ module.exports = { const listeningSessions = await this.getListeningSessionsForYear(year) let totalListeningTime = 0 for (const ls of listeningSessions) { - totalListeningTime += (ls.timeListening || 0) + totalListeningTime += ls.timeListening || 0 - const authors = ls.mediaMetadata.authors || [] + const authors = ls.mediaMetadata?.authors || [] authors.forEach((au) => { if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 - authorListeningMap[au.name] += (ls.timeListening || 0) + authorListeningMap[au.name] += ls.timeListening || 0 }) - const narrators = ls.mediaMetadata.narrators || [] + const narrators = ls.mediaMetadata?.narrators || [] narrators.forEach((narrator) => { if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 - narratorListeningMap[narrator] += (ls.timeListening || 0) + narratorListeningMap[narrator] += ls.timeListening || 0 }) // Filter out bad genres like "audiobook" and "audio book" - const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) genres.forEach((genre) => { if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 - genreListeningMap[genre] += (ls.timeListening || 0) + genreListeningMap[genre] += ls.timeListening || 0 }) } let topAuthors = null - topAuthors = Object.keys(authorListeningMap).map(authorName => ({ - name: authorName, - time: Math.round(authorListeningMap[authorName]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topAuthors = Object.keys(authorListeningMap) + .map((authorName) => ({ + name: authorName, + time: Math.round(authorListeningMap[authorName]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) let topNarrators = null - topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({ - name: narratorName, - time: Math.round(narratorListeningMap[narratorName]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topNarrators = Object.keys(narratorListeningMap) + .map((narratorName) => ({ + name: narratorName, + time: Math.round(narratorListeningMap[narratorName]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) let topGenres = null - topGenres = Object.keys(genreListeningMap).map(genre => ({ - genre, - time: Math.round(genreListeningMap[genre]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topGenres = Object.keys(genreListeningMap) + .map((genre) => ({ + genre, + time: Math.round(genreListeningMap[genre]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) // Stats for total books, size and duration for everything added this year or earlier const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, { From 615ed26f0ffb8b2af9517d08a3e57208db99f243 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Dec 2024 17:35:35 -0600 Subject: [PATCH 440/539] Update:Users table show count next to header --- client/components/tables/UsersTable.vue | 1 + client/pages/config/users/index.vue | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index 92fa684efe..09db334181 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -120,6 +120,7 @@ export default { this.users = res.users.sort((a, b) => { return a.createdAt - b.createdAt }) + this.$emit('numUsers', this.users.length) }) .catch((error) => { console.error('Failed', error) diff --git a/client/pages/config/users/index.vue b/client/pages/config/users/index.vue index 4dd8259109..184529cbe1 100644 --- a/client/pages/config/users/index.vue +++ b/client/pages/config/users/index.vue @@ -2,6 +2,10 @@
- +
@@ -29,7 +33,8 @@ export default { data() { return { selectedAccount: null, - showAccountModal: false + showAccountModal: false, + numUsers: 0 } }, computed: {}, From 0f1b64b883479401d09ad3f45c76a976e1af4211 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 3 Dec 2024 17:21:57 -0600 Subject: [PATCH 441/539] Add test for grouping book library items --- test/server/utils/scandir.test.js | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 test/server/utils/scandir.test.js diff --git a/test/server/utils/scandir.test.js b/test/server/utils/scandir.test.js new file mode 100644 index 0000000000..a5ff6ae0ec --- /dev/null +++ b/test/server/utils/scandir.test.js @@ -0,0 +1,52 @@ +const Path = require('path') +const chai = require('chai') +const expect = chai.expect +const scanUtils = require('../../../server/utils/scandir') + +describe('scanUtils', async () => { + it('should properly group files into potential book library items', async () => { + global.isWin = process.platform === 'win32' + global.ServerSettings = { + scannerParseSubtitle: true + } + + const filePaths = [ + 'randomfile.txt', // Should be ignored because it's not a book media file + 'Book1.m4b', // Root single file audiobook + 'Book2/audiofile.m4b', + 'Book2/disk 001/audiofile.m4b', + 'Book2/disk 002/audiofile.m4b', + 'Author/Book3/audiofile.mp3', + 'Author/Book3/Disc 1/audiofile.mp3', + 'Author/Book3/Disc 2/audiofile.mp3', + 'Author/Series/Book4/cover.jpg', + 'Author/Series/Book4/CD1/audiofile.mp3', + 'Author/Series/Book4/CD2/audiofile.mp3', + 'Author/Series2/Book5/deeply/nested/cd 01/audiofile.mp3', + 'Author/Series2/Book5/deeply/nested/cd 02/audiofile.mp3', + 'Author/Series2/Book5/randomfile.js' // Should be ignored because it's not a book media file + ] + + // Create fileItems to match the format of fileUtils.recurseFiles + const fileItems = [] + for (const filePath of filePaths) { + const dirname = Path.dirname(filePath) + fileItems.push({ + name: Path.basename(filePath), + reldirpath: dirname === '.' ? '' : dirname, + extension: Path.extname(filePath), + deep: filePath.split('/').length - 1 + }) + } + + const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs('book', fileItems, false) + + expect(libraryItemGrouping).to.deep.equal({ + 'Book1.m4b': 'Book1.m4b', + Book2: ['audiofile.m4b', 'disk 001/audiofile.m4b', 'disk 002/audiofile.m4b'], + 'Author/Book3': ['audiofile.mp3', 'Disc 1/audiofile.mp3', 'Disc 2/audiofile.mp3'], + 'Author/Series/Book4': ['CD1/audiofile.mp3', 'CD2/audiofile.mp3', 'cover.jpg'], + 'Author/Series2/Book5/deeply/nested': ['cd 01/audiofile.mp3', 'cd 02/audiofile.mp3'] + }) + }) +}) From 344890fb45e9cbf9c3421b97007dc99e6c5b24c0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Dec 2024 16:25:17 -0600 Subject: [PATCH 442/539] Update watcher files changed function to use the same grouping function as other scans --- server/scanner/LibraryScanner.js | 4 +- server/utils/fileUtils.js | 33 +++++++++- server/utils/scandir.js | 101 ------------------------------- 3 files changed, 33 insertions(+), 105 deletions(-) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index bd0bb310f5..a52350f654 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -424,8 +424,8 @@ class LibraryScanner { } const folder = library.libraryFolders[0] - const relFilePaths = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUpdate.relPath) - const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths) + const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate)) + const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly) if (!Object.keys(fileUpdateGroup).length) { Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index b0c73d6c6e..8b87d3a09c 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -131,11 +131,21 @@ async function readTextFile(path) { } module.exports.readTextFile = readTextFile +/** + * @typedef FilePathItem + * @property {string} name - file name e.g. "audiofile.m4b" + * @property {string} path - fullpath excluding folder e.g. "Author/Book/audiofile.m4b" + * @property {string} reldirpath - path excluding file name e.g. "Author/Book" + * @property {string} fullpath - full path e.g. "/audiobooks/Author/Book/audiofile.m4b" + * @property {string} extension - file extension e.g. ".m4b" + * @property {number} deep - depth of file in directory (0 is file in folder root) + */ + /** * Get array of files inside dir * @param {string} path * @param {string} [relPathToReplace] - * @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} + * @returns {FilePathItem[]} */ async function recurseFiles(path, relPathToReplace = null) { path = filePathToPOSIX(path) @@ -213,7 +223,6 @@ async function recurseFiles(path, relPathToReplace = null) { return { name: item.name, path: item.fullname.replace(relPathToReplace, ''), - dirpath: item.path, reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''), fullpath: item.fullname, extension: item.extension, @@ -228,6 +237,26 @@ async function recurseFiles(path, relPathToReplace = null) { } module.exports.recurseFiles = recurseFiles +/** + * + * @param {import('../Watcher').PendingFileUpdate} fileUpdate + * @returns {FilePathItem} + */ +module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => { + let relPath = fileUpdate.relPath + if (relPath.startsWith('/')) relPath = relPath.slice(1) + + const dirname = Path.dirname(relPath) + return { + name: Path.basename(relPath), + path: relPath, + reldirpath: dirname === '.' ? '' : dirname, + fullpath: fileUpdate.path, + extension: Path.extname(relPath), + deep: relPath.split('/').length - 1 + } +} + /** * Download file from web to local file system * Uses SSRF filter to prevent internal URLs diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 028a1022db..a70e09bb07 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -32,107 +32,6 @@ function checkFilepathIsAudioFile(filepath) { } module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile -/** - * TODO: Function needs to be re-done - * @param {string} mediaType - * @param {string[]} paths array of relative file paths - * @returns {Record} map of files grouped into potential libarary item dirs - */ -function groupFilesIntoLibraryItemPaths(mediaType, paths) { - // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir - var nonMediaFilePaths = [] - var pathsFiltered = paths - .map((path) => { - return path.startsWith('/') ? path.slice(1) : path - }) - .filter((path) => { - let parsedPath = Path.parse(path) - // Is not in root dir OR is a book media file - if (parsedPath.dir) { - if (!isMediaFile(mediaType, parsedPath.ext, false)) { - // Seperate out non-media files - nonMediaFilePaths.push(path) - return false - } - return true - } else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) { - // (book media type supports single file audiobooks/ebooks in root dir) - return true - } - return false - }) - - // Step 2: Sort by least number of directories - pathsFiltered.sort((a, b) => { - var pathsA = Path.dirname(a).split('/').length - var pathsB = Path.dirname(b).split('/').length - return pathsA - pathsB - }) - - // Step 3: Group files in dirs - var itemGroup = {} - pathsFiltered.forEach((path) => { - var dirparts = Path.dirname(path) - .split('/') - .filter((p) => !!p && p !== '.') // dirname returns . if no directory - var numparts = dirparts.length - var _path = '' - - if (!numparts) { - // Media file in root - itemGroup[path] = path - } else { - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) - - if (itemGroup[_path]) { - // Directory already has files, add file - var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) - itemGroup[_path].push(relpath) - return - } else if (!dirparts.length) { - // This is the last directory, create group - itemGroup[_path] = [Path.basename(path)] - return - } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { - // Next directory is the last and is a CD dir, create group - itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] - return - } - } - } - }) - - // Step 4: Add in non-media files if they fit into item group - if (nonMediaFilePaths.length) { - for (const nonMediaFilePath of nonMediaFilePaths) { - const pathDir = Path.dirname(nonMediaFilePath) - const filename = Path.basename(nonMediaFilePath) - const dirparts = pathDir.split('/') - const numparts = dirparts.length - let _path = '' - - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - const dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) - if (itemGroup[_path]) { - // Directory is a group - const relpath = Path.posix.join(dirparts.join('/'), filename) - itemGroup[_path].push(relpath) - } else if (!dirparts.length) { - itemGroup[_path] = [filename] - } - } - } - } - - return itemGroup -} -module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths - /** * @param {string} mediaType * @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles) From 9774b2cfa50b235a17406e0985723d3454f31433 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Dec 2024 16:30:35 -0600 Subject: [PATCH 443/539] Update JSDocs for groupFileItemsIntoLibraryItemDirs --- server/utils/scandir.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index a70e09bb07..f59d0a5bc4 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -34,7 +34,7 @@ module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile /** * @param {string} mediaType - * @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles) + * @param {import('./fileUtils').FilePathItem[]} fileItems * @param {boolean} [audiobooksOnly=false] * @returns {Record} map of files grouped into potential libarary item dirs */ @@ -46,7 +46,9 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly // Step 2: Seperate media files and other files // - Directories without a media file will not be included + /** @type {import('./fileUtils').FilePathItem[]} */ const mediaFileItems = [] + /** @type {import('./fileUtils').FilePathItem[]} */ const otherFileItems = [] itemsFiltered.forEach((item) => { if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item) From c35185fff722706d629cd56b806a4e2a735cd791 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Dec 2024 16:15:23 -0600 Subject: [PATCH 444/539] Update prober to accept grp1 as an alternative tag to grouping #3681 --- server/utils/prober.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/prober.js b/server/utils/prober.js index b54b981d24..838899bdc0 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -189,7 +189,7 @@ function parseTags(format, verbose) { file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'), file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'), file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'), - file_tag_grouping: tryGrabTags(format, 'grouping'), + file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'), file_tag_isbn: tryGrabTags(format, 'isbn'), // custom file_tag_language: tryGrabTags(format, 'language', 'lang'), file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom From 252a233282b5001e38e5c222f02c78ede3e9adc3 Mon Sep 17 00:00:00 2001 From: Henning Date: Mon, 2 Dec 2024 10:46:18 +0000 Subject: [PATCH 445/539] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 030f8f1b3b..1ea58b5b05 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -728,7 +728,7 @@ "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter /metadata/cache/items gelöscht.
Bist du dir sicher?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt.

Möchtest du fortfahren?", - "MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?", + "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", From 68413ae2f62e86d8ffa946877fb6a8fede43c56b Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 2 Dec 2024 06:00:11 +0000 Subject: [PATCH 446/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 02c1fb132c..e80ac8b271 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -184,7 +184,7 @@ "HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod", "HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice", "HeaderSession": "Seja", - "HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja", + "HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja", "HeaderSettings": "Nastavitve", "HeaderSettingsDisplay": "Zaslon", "HeaderSettingsExperimental": "Eksperimentalne funkcije", @@ -830,7 +830,7 @@ "MessageSearchResultsFor": "Rezultati iskanja za", "MessageSelected": "{0} izbrano", "MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči", - "MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", + "MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", "MessageShareExpirationWillBe": "Potečeno bo {0}", "MessageShareExpiresIn": "Poteče čez {0}", "MessageShareURLWillBe": "URL za skupno rabo bo {0}", From cbee6d8f5e74d2518ad27125314c495ec109caba Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 2 Dec 2024 12:01:08 +0000 Subject: [PATCH 447/539] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 1ea58b5b05..7f78360ccc 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -728,7 +728,7 @@ "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter /metadata/cache/items gelöscht.
Bist du dir sicher?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt.

Möchtest du fortfahren?", - "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", + "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", @@ -833,7 +833,7 @@ "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageShareExpirationWillBe": "Läuft am {0} ab", "MessageShareExpiresIn": "Läuft in {0} ab", - "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein.", + "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", "MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt", "MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen", @@ -1041,7 +1041,7 @@ "ToastRenameFailed": "Umbenennen fehlgeschlagen", "ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}", "ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt", - "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand", + "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand", "ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert", "ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek", "ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus", From 658ac042685690d18f39678b4667d4b24700781a Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 3 Dec 2024 14:09:47 +0000 Subject: [PATCH 448/539] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 7f78360ccc..865065aa7f 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -584,7 +584,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", "LabelShare": "Freigeben", - "LabelShareOpen": "Freigabe", + "LabelShareOpen": "Freigeben", "LabelShareURL": "Freigabe URL", "LabelShowAll": "Alles anzeigen", "LabelShowSeconds": "Zeige Sekunden", From 079a15541c6393f727f3684b32f25dc8f7f3e729 Mon Sep 17 00:00:00 2001 From: Milo Ivir Date: Tue, 3 Dec 2024 16:35:13 +0000 Subject: [PATCH 449/539] Translated using Weblate (Croatian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index a7f2562b79..6ed299fbba 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -532,7 +532,7 @@ "LabelSelectAllEpisodes": "Označi sve nastavke", "LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka", "LabelSelectUsers": "Označi korisnike", - "LabelSendEbookToDevice": "Pošalji e-knjigu", + "LabelSendEbookToDevice": "Pošalji e-knjigu …", "LabelSequence": "Slijed", "LabelSerial": "Serijal", "LabelSeries": "Serijal", From 67952cc57732317cb39d104053c9e829e8155ce3 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Wed, 4 Dec 2024 10:06:23 +0000 Subject: [PATCH 450/539] Translated using Weblate (Spanish) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index 76a62c1618..87956e54be 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Ver los ajustes del reproductor", "LabelViewQueue": "Ver Fila del Reproductor", "LabelVolume": "Volumen", + "LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:", + "LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento", "LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelXBooks": "{0} libros", "LabelXItems": "{0} elementos", From 867354e59d12c5cfa107af1af30f08fd59b8e945 Mon Sep 17 00:00:00 2001 From: Milo Ivir Date: Wed, 4 Dec 2024 20:56:24 +0000 Subject: [PATCH 451/539] Translated using Weblate (Croatian) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 6ed299fbba..48d9b5a0f1 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -271,7 +271,7 @@ "LabelCollapseSubSeries": "Podserijale prikaži sažeto", "LabelCollection": "Zbirka", "LabelCollections": "Zbirke", - "LabelComplete": "Dovršeno", + "LabelComplete": "Potpuno", "LabelConfirmPassword": "Potvrda zaporke", "LabelContinueListening": "Nastavi slušati", "LabelContinueReading": "Nastavi čitati", @@ -567,7 +567,7 @@ "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)", "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal", - "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", "LabelSettingsParseSubtitles": "Raščlani podnaslove", "LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.
Podnaslov mora biti odvojen s \" - \"
npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"", "LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki", @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Pogledaj postavke reproduktora", "LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora", "LabelVolume": "Glasnoća", + "LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:", + "LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja", "LabelWeekdaysToRun": "Dani u tjednu za pokretanje", "LabelXBooks": "{0} knjiga", "LabelXItems": "{0} stavki", From f467c44543c6e1a43b688086c778e6df4abb8941 Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Wed, 4 Dec 2024 06:13:20 +0000 Subject: [PATCH 452/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.9% (1073 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 072cbd39ef..db262448de 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUpdatedAt": "更新时间", "LabelUploaderDragAndDrop": "拖放文件或文件夹", + "LabelUploaderDragAndDropFilesOnly": "拖放文件", "LabelUploaderDropFiles": "删除文件", "LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列", "LabelUseAdvancedOptions": "使用高级选项", @@ -678,6 +679,7 @@ "LabelViewPlayerSettings": "查看播放器设置", "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", + "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 7334580c8c5221ea82adb01070d82d5c2367af62 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:19:57 +0000 Subject: [PATCH 453/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index db262448de..23137053b7 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,6 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", + "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 14f60a593b14c3473140603fc4a3eea4dd446d00 Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Thu, 5 Dec 2024 13:20:37 +0000 Subject: [PATCH 454/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 23137053b7..a277ecfd16 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,7 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", - "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", + "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 259d93d8827ad2c6dd202ecee77a09378f4006ec Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:22:25 +0000 Subject: [PATCH 455/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index a277ecfd16..6eea0a603c 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -679,7 +679,7 @@ "LabelViewPlayerSettings": "查看播放器设置", "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", - "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", + "LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:", "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", From 1ff79520743558569a1b8997e6588ea233c479db Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:23:34 +0000 Subject: [PATCH 456/539] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 6eea0a603c..e4791aff50 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,7 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:", - "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", + "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 890b0b949ee758102fd05ba26c5ed5c3ebbd747f Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Dec 2024 16:50:30 -0600 Subject: [PATCH 457/539] Version bump v2.17.4 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 588ad79dd8..e4e3236cec 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index c1a43e5257..ea19190175 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 062fb03221..10db84ea92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index db63261b1d..c122240a7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 9a1c773b7a26f0974824eaa83d135caeb0ebfc58 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Dec 2024 16:59:34 -0600 Subject: [PATCH 458/539] Fix:Server crash on uploadCover temp file mv failed #3685 --- server/managers/CoverManager.js | 76 +++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 9b4aa32d09..2b3a697d75 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const CacheManager = require('../managers/CacheManager') class CoverManager { - constructor() { } + constructor() {} getCoverDirectory(libraryItem) { if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) { @@ -93,10 +93,13 @@ class CoverManager { const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`) // Move cover from temp upload dir to destination - const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { - Logger.error('[CoverManager] Failed to move cover file', path, error) - return false - }) + const success = await coverFile + .mv(coverFullPath) + .then(() => true) + .catch((error) => { + Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error) + return false + }) if (!success) { return { @@ -124,11 +127,13 @@ class CoverManager { var temppath = Path.posix.join(coverDirPath, 'cover') let errorMsg = '' - let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { - errorMsg = err.message || 'Unknown error' - Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) - return false - }) + let success = await downloadImageFile(url, temppath) + .then(() => true) + .catch((err) => { + errorMsg = err.message || 'Unknown error' + Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) + return false + }) if (!success) { return { error: 'Failed to download image from url: ' + errorMsg @@ -180,7 +185,7 @@ class CoverManager { } // Cover path does not exist - if (!await fs.pathExists(coverPath)) { + if (!(await fs.pathExists(coverPath))) { Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`) return { error: 'Cover path does not exist' @@ -188,7 +193,7 @@ class CoverManager { } // Cover path is not a file - if (!await checkPathIsFile(coverPath)) { + if (!(await checkPathIsFile(coverPath))) { Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`) return { error: 'Cover path is not a file' @@ -211,10 +216,13 @@ class CoverManager { var newCoverPath = Path.posix.join(coverDirPath, coverFilename) Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`) - var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => { - Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) - return false - }) + var copySuccess = await fs + .copy(coverPath, newCoverPath, { overwrite: true }) + .then(() => true) + .catch((error) => { + Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) + return false + }) if (!copySuccess) { return { error: 'Failed to copy cover to dir' @@ -236,14 +244,14 @@ class CoverManager { /** * Extract cover art from audio file and save for library item - * - * @param {import('../models/Book').AudioFileObject[]} audioFiles - * @param {string} libraryItemId - * @param {string} [libraryItemPath] null for isFile library items + * + * @param {import('../models/Book').AudioFileObject[]} audioFiles + * @param {string} libraryItemId + * @param {string} [libraryItemPath] null for isFile library items * @returns {Promise} returns cover path */ async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) { - let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt) + let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt) if (!audioFileWithCover) return null let coverDirPath = null @@ -273,10 +281,10 @@ class CoverManager { /** * Extract cover art from ebook and save for library item - * - * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData - * @param {string} libraryItemId - * @param {string} [libraryItemPath] null for isFile library items + * + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData + * @param {string} libraryItemId + * @param {string} [libraryItemPath] null for isFile library items * @returns {Promise} returns cover path */ async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) { @@ -310,9 +318,9 @@ class CoverManager { } /** - * - * @param {string} url - * @param {string} libraryItemId + * + * @param {string} url + * @param {string} libraryItemId * @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast * @returns {Promise<{error:string}|{cover:string}>} */ @@ -328,10 +336,12 @@ class CoverManager { await fs.ensureDir(coverDirPath) const temppath = Path.posix.join(coverDirPath, 'cover') - const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { - Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) - return false - }) + const success = await downloadImageFile(url, temppath) + .then(() => true) + .catch((err) => { + Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) + return false + }) if (!success) { return { error: 'Failed to download image from url' @@ -361,4 +371,4 @@ class CoverManager { } } } -module.exports = new CoverManager() \ No newline at end of file +module.exports = new CoverManager() From 3b4a5b8785fff8672abb76fae4325c49b7ffca26 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Dec 2024 17:17:32 -0600 Subject: [PATCH 459/539] Support ALLOW_IFRAME env variable to not include frame-ancestors header #3684 --- index.js | 1 + server/Server.js | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index de1ed5c302..9a0be347cc 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ if (isDev) { if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' + if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' diff --git a/server/Server.js b/server/Server.js index 9153ab0921..cd96733e98 100644 --- a/server/Server.js +++ b/server/Server.js @@ -53,6 +53,7 @@ class Server { global.RouterBasePath = ROUTER_BASE_PATH global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' + global.AllowIframe = process.env.ALLOW_IFRAME === '1' global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' if (!fs.pathExistsSync(global.ConfigPath)) { @@ -194,8 +195,10 @@ class Server { const app = express() app.use((req, res, next) => { - // Prevent clickjacking by disallowing iframes - res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") + if (!global.AllowIframe) { + // Prevent clickjacking by disallowing iframes + res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") + } /** * @temporary From 835490a9fcecf0ea608179071dad2fc5d2b17b3b Mon Sep 17 00:00:00 2001 From: Jaume Date: Sat, 7 Dec 2024 01:45:41 +0100 Subject: [PATCH 460/539] Catalan translation added new file client/strings/ca.json --- client/strings/ca.json | 1029 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1029 insertions(+) create mode 100644 client/strings/ca.json diff --git a/client/strings/ca.json b/client/strings/ca.json new file mode 100644 index 0000000000..8dde850b84 --- /dev/null +++ b/client/strings/ca.json @@ -0,0 +1,1029 @@ +{ + "ButtonAdd": "Afegeix", + "ButtonAddChapters": "Afegeix", + "ButtonAddDevice": "Afegeix Dispositiu", + "ButtonAddLibrary": "Crea Biblioteca", + "ButtonAddPodcasts": "Afegeix Podcasts", + "ButtonAddUser": "Crea Usuari", + "ButtonAddYourFirstLibrary": "Crea la teva Primera Biblioteca", + "ButtonApply": "Aplica", + "ButtonApplyChapters": "Aplica Capítols", + "ButtonAuthors": "Autors", + "ButtonBack": "Enrere", + "ButtonBrowseForFolder": "Cerca Carpeta", + "ButtonCancel": "Cancel·la", + "ButtonCancelEncode": "Cancel·la Codificador", + "ButtonChangeRootPassword": "Canvia Contrasenya Root", + "ButtonCheckAndDownloadNewEpisodes": "Verifica i Descarrega Nous Episodis", + "ButtonChooseAFolder": "Tria una Carpeta", + "ButtonChooseFiles": "Tria un Fitxer", + "ButtonClearFilter": "Elimina Filtres", + "ButtonCloseFeed": "Tanca Font", + "ButtonCloseSession": "Tanca la sessió oberta", + "ButtonCollections": "Col·leccions", + "ButtonConfigureScanner": "Configura Escàner", + "ButtonCreate": "Crea", + "ButtonCreateBackup": "Crea Còpia de Seguretat", + "ButtonDelete": "Elimina", + "ButtonDownloadQueue": "Cua", + "ButtonEdit": "Edita", + "ButtonEditChapters": "Edita Capítol", + "ButtonEditPodcast": "Edita Podcast", + "ButtonEnable": "Habilita", + "ButtonFireAndFail": "Executat i fallat", + "ButtonFireOnTest": "Activa esdeveniment de prova", + "ButtonForceReScan": "Força Re-escaneig", + "ButtonFullPath": "Ruta Completa", + "ButtonHide": "Amaga", + "ButtonHome": "Inici", + "ButtonIssues": "Problemes", + "ButtonJumpBackward": "Retrocedeix", + "ButtonJumpForward": "Avança", + "ButtonLatest": "Últims", + "ButtonLibrary": "Biblioteca", + "ButtonLogout": "Tanca Sessió", + "ButtonLookup": "Cerca", + "ButtonManageTracks": "Gestiona Pistes d'Àudio", + "ButtonMapChapterTitles": "Assigna Títols als Capítols", + "ButtonMatchAllAuthors": "Troba Tots els Autors", + "ButtonMatchBooks": "Troba Llibres", + "ButtonNevermind": "Oblida-ho", + "ButtonNext": "Següent", + "ButtonNextChapter": "Següent Capítol", + "ButtonNextItemInQueue": "Següent element a la cua", + "ButtonOk": "D'acord", + "ButtonOpenFeed": "Obre Font", + "ButtonOpenManager": "Obre Editor", + "ButtonPause": "Pausa", + "ButtonPlay": "Reprodueix", + "ButtonPlayAll": "Reprodueix tot", + "ButtonPlaying": "Reproduint", + "ButtonPlaylists": "Llistes de reproducció", + "ButtonPrevious": "Anterior", + "ButtonPreviousChapter": "Capítol Anterior", + "ButtonProbeAudioFile": "Examina fitxer d'àudio", + "ButtonPurgeAllCache": "Esborra Tot el Cache", + "ButtonPurgeItemsCache": "Esborra Cache d'Elements", + "ButtonQueueAddItem": "Afegeix a la Cua", + "ButtonQueueRemoveItem": "Elimina de la Cua", + "ButtonQuickEmbed": "Inserció Ràpida", + "ButtonQuickEmbedMetadata": "Afegeix Metadades Ràpidament", + "ButtonQuickMatch": "Troba Ràpidament", + "ButtonReScan": "Re-escaneja", + "ButtonRead": "Llegeix", + "ButtonReadLess": "Llegeix menys", + "ButtonReadMore": "Llegeix més", + "ButtonRefresh": "Refresca", + "ButtonRemove": "Elimina", + "ButtonRemoveAll": "Elimina Tot", + "ButtonRemoveAllLibraryItems": "Elimina Tots els Elements de la Biblioteca", + "ButtonRemoveFromContinueListening": "Elimina de Continuar Escoltant", + "ButtonRemoveFromContinueReading": "Elimina de Continuar Llegint", + "ButtonRemoveSeriesFromContinueSeries": "Elimina Sèrie de Continuar Sèries", + "ButtonReset": "Restableix", + "ButtonResetToDefault": "Restaura Valors per Defecte", + "ButtonRestore": "Restaura", + "ButtonSave": "Desa", + "ButtonSaveAndClose": "Desa i Tanca", + "ButtonSaveTracklist": "Desa Pistes", + "ButtonScan": "Escaneja", + "ButtonScanLibrary": "Escaneja Biblioteca", + "ButtonSearch": "Cerca", + "ButtonSelectFolderPath": "Selecciona Ruta de Carpeta", + "ButtonSeries": "Sèries", + "ButtonSetChaptersFromTracks": "Selecciona Capítols Segons les Pistes", + "ButtonShare": "Comparteix", + "ButtonShiftTimes": "Desplaça Temps", + "ButtonShow": "Mostra", + "ButtonStartM4BEncode": "Inicia Codificació M4B", + "ButtonStartMetadataEmbed": "Inicia Inserció de Metadades", + "ButtonStats": "Estadístiques", + "ButtonSubmit": "Envia", + "ButtonTest": "Prova", + "ButtonUnlinkOpenId": "Desvincula OpenID", + "ButtonUpload": "Carrega", + "ButtonUploadBackup": "Carrega Còpia de Seguretat", + "ButtonUploadCover": "Carrega Portada", + "ButtonUploadOPMLFile": "Carrega Fitxer OPML", + "ButtonUserDelete": "Elimina Usuari {0}", + "ButtonUserEdit": "Edita Usuari {0}", + "ButtonViewAll": "Mostra-ho Tot", + "ButtonYes": "Sí", + "ErrorUploadFetchMetadataAPI": "Error obtenint metadades", + "ErrorUploadFetchMetadataNoResults": "No s'han pogut obtenir metadades - Intenta actualitzar el títol i/o autor", + "ErrorUploadLacksTitle": "S'ha de tenir un títol", + "HeaderAccount": "Compte", + "HeaderAddCustomMetadataProvider": "Afegeix un proveïdor de metadades personalitzat", + "HeaderAdvanced": "Avançat", + "HeaderAppriseNotificationSettings": "Configuració de Notificacions Apprise", + "HeaderAudioTracks": "Pistes d'àudio", + "HeaderAudiobookTools": "Eines de Gestió d'Arxius d'Audiollibre", + "HeaderAuthentication": "Autenticació", + "HeaderBackups": "Còpies de Seguretat", + "HeaderChangePassword": "Canvia Contrasenya", + "HeaderChapters": "Capítols", + "HeaderChooseAFolder": "Tria una Carpeta", + "HeaderCollection": "Col·lecció", + "HeaderCollectionItems": "Elements a la Col·lecció", + "HeaderCover": "Portada", + "HeaderCurrentDownloads": "Descàrregues Actuals", + "HeaderCustomMessageOnLogin": "Missatge Personalitzat a l'Iniciar Sessió", + "HeaderCustomMetadataProviders": "Proveïdors de Metadades Personalitzats", + "HeaderDetails": "Detalls", + "HeaderDownloadQueue": "Cua de Descàrregues", + "HeaderEbookFiles": "Fitxers de Llibres Digitals", + "HeaderEmail": "Correu electrònic", + "HeaderEmailSettings": "Configuració de Correu Electrònic", + "HeaderEpisodes": "Episodis", + "HeaderEreaderDevices": "Dispositius Ereader", + "HeaderEreaderSettings": "Configuració del Lector", + "HeaderFiles": "Element", + "HeaderFindChapters": "Cerca Capítol", + "HeaderIgnoredFiles": "Ignora Element", + "HeaderItemFiles": "Carpetes d'Elements", + "HeaderItemMetadataUtils": "Utilitats de Metadades d'Elements", + "HeaderLastListeningSession": "Últimes Sessions", + "HeaderLatestEpisodes": "Últims Episodis", + "HeaderLibraries": "Biblioteques", + "HeaderLibraryFiles": "Fitxers de Biblioteca", + "HeaderLibraryStats": "Estadístiques de Biblioteca", + "HeaderListeningSessions": "Sessió", + "HeaderListeningStats": "Estadístiques de Temps Escoltat", + "HeaderLogin": "Inicia Sessió", + "HeaderLogs": "Registres", + "HeaderManageGenres": "Gestiona Gèneres", + "HeaderManageTags": "Gestiona Etiquetes", + "HeaderMapDetails": "Assigna Detalls", + "HeaderMatch": "Troba", + "HeaderMetadataOrderOfPrecedence": "Ordre de Precedència de Metadades", + "HeaderMetadataToEmbed": "Metadades a Inserir", + "HeaderNewAccount": "Nou Compte", + "HeaderNewLibrary": "Nova Biblioteca", + "HeaderNotificationCreate": "Crea Notificació", + "HeaderNotificationUpdate": "Actualització de Notificació", + "HeaderNotifications": "Notificacions", + "HeaderOpenIDConnectAuthentication": "Autenticació OpenID Connect", + "HeaderOpenListeningSessions": "Sessions públiques d'escolta", + "HeaderOpenRSSFeed": "Obre Font RSS", + "HeaderOtherFiles": "Altres Fitxers", + "HeaderPasswordAuthentication": "Autenticació per Contrasenya", + "HeaderPermissions": "Permisos", + "HeaderPlayerQueue": "Cua del Reproductor", + "HeaderPlayerSettings": "Configuració del Reproductor", + "HeaderPlaylist": "Llista de Reproducció", + "HeaderPlaylistItems": "Elements de la Llista de Reproducció", + "HeaderPodcastsToAdd": "Podcasts a afegir", + "HeaderPreviewCover": "Previsualització de la Portada", + "HeaderRSSFeedGeneral": "Detalls RSS", + "HeaderRSSFeedIsOpen": "La Font RSS està oberta", + "HeaderRSSFeeds": "Fonts RSS", + "HeaderRemoveEpisode": "Elimina Episodi", + "HeaderRemoveEpisodes": "Elimina {0} Episodis", + "HeaderSavedMediaProgress": "Desa el Progrés del Multimèdia", + "HeaderSchedule": "Horari", + "HeaderScheduleEpisodeDownloads": "Programa Descàrregues Automàtiques d'Episodis", + "HeaderScheduleLibraryScans": "Programa Escaneig Automàtic de Biblioteca", + "HeaderSession": "Sessió", + "HeaderSetBackupSchedule": "Programa Còpies de Seguretat", + "HeaderSettings": "Configuració", + "HeaderSettingsDisplay": "Interfície", + "HeaderSettingsExperimental": "Funcions Experimentals", + "HeaderSettingsGeneral": "General", + "HeaderSettingsScanner": "Escàner", + "HeaderSleepTimer": "Temporitzador de son", + "HeaderStatsLargestItems": "Elements més Grans", + "HeaderStatsLongestItems": "Elements més Llargs (h)", + "HeaderStatsMinutesListeningChart": "Minuts Escoltant (Últims 7 dies)", + "HeaderStatsRecentSessions": "Sessions Recents", + "HeaderStatsTop10Authors": "Top 10 Autors", + "HeaderStatsTop5Genres": "Top 5 Gèneres", + "HeaderTableOfContents": "Taula de Continguts", + "HeaderTools": "Eines", + "HeaderUpdateAccount": "Actualitza Compte", + "HeaderUpdateAuthor": "Actualitza Autor", + "HeaderUpdateDetails": "Actualitza Detalls", + "HeaderUpdateLibrary": "Actualitza Biblioteca", + "HeaderUsers": "Usuaris", + "HeaderYearReview": "Revisió de l'Any {0}", + "HeaderYourStats": "Les teves Estadístiques", + "LabelAbridged": "Resumit", + "LabelAbridgedChecked": "Resumit (comprovat)", + "LabelAbridgedUnchecked": "Sense resumir (no comprovat)", + "LabelAccessibleBy": "Accessible per", + "LabelAccountType": "Tipus de Compte", + "LabelAccountTypeAdmin": "Administrador", + "LabelAccountTypeGuest": "Convidat", + "LabelAccountTypeUser": "Usuari", + "LabelActivity": "Activitat", + "LabelAddToCollection": "Afegit a la Col·lecció", + "LabelAddToCollectionBatch": "S'han Afegit {0} Llibres a la Col·lecció", + "LabelAddToPlaylist": "Afegit a la llista de reproducció", + "LabelAddToPlaylistBatch": "S'han Afegit {0} Elements a la Llista de Reproducció", + "LabelAddedAt": "Afegit", + "LabelAddedDate": "{0} Afegit", + "LabelAdminUsersOnly": "Només usuaris administradors", + "LabelAll": "Tots", + "LabelAllUsers": "Tots els Usuaris", + "LabelAllUsersExcludingGuests": "Tots els usuaris excepte convidats", + "LabelAllUsersIncludingGuests": "Tots els usuaris i convidats", + "LabelAlreadyInYourLibrary": "Ja existeix a la Biblioteca", + "LabelApiToken": "Token de l'API", + "LabelAppend": "Adjuntar", + "LabelAudioBitrate": "Taxa de bits d'àudio (per exemple, 128k)", + "LabelAudioChannels": "Canals d'àudio (1 o 2)", + "LabelAudioCodec": "Còdec d'àudio", + "LabelAuthor": "Autor", + "LabelAuthorFirstLast": "Autor (Nom Cognom)", + "LabelAuthorLastFirst": "Autor (Cognom, Nom)", + "LabelAuthors": "Autors", + "LabelAutoDownloadEpisodes": "Descarregar episodis automàticament", + "LabelAutoFetchMetadata": "Actualitzar Metadades Automàticament", + "LabelAutoFetchMetadataHelp": "Obtén metadades de títol, autor i sèrie per agilitzar la càrrega. És possible que calgui revisar metadades addicionals després de la càrrega.", + "LabelAutoLaunch": "Inici automàtic", + "LabelAutoLaunchDescription": "Redirigir automàticament al proveïdor d'autenticació quan s'accedeix a la pàgina d'inici de sessió (ruta d'excepció manual /login?autoLaunch=0)", + "LabelAutoRegister": "Registre automàtic", + "LabelAutoRegisterDescription": "Crear usuaris automàticament en iniciar sessió", + "LabelBackToUser": "Torna a Usuari", + "LabelBackupAudioFiles": "Còpia de seguretat d'arxius d'àudio", + "LabelBackupLocation": "Ubicació de la Còpia de Seguretat", + "LabelBackupsEnableAutomaticBackups": "Habilitar Còpies de Seguretat Automàtiques", + "LabelBackupsEnableAutomaticBackupsHelp": "Còpies de seguretat desades a /metadata/backups", + "LabelBackupsMaxBackupSize": "Mida màxima de la còpia de seguretat (en GB) (0 per il·limitat)", + "LabelBackupsMaxBackupSizeHelp": "Com a protecció contra una configuració incorrecta, les còpies de seguretat fallaran si superen la mida configurada.", + "LabelBackupsNumberToKeep": "Nombre de còpies de seguretat a conservar", + "LabelBackupsNumberToKeepHelp": "Només s'eliminarà una còpia de seguretat alhora. Si té més còpies desades, haurà d'eliminar-les manualment.", + "LabelBitrate": "Taxa de bits", + "LabelBonus": "Bonus", + "LabelBooks": "Llibres", + "LabelButtonText": "Text del botó", + "LabelByAuthor": "per {0}", + "LabelChangePassword": "Canviar Contrasenya", + "LabelChannels": "Canals", + "LabelChapterCount": "{0} capítols", + "LabelChapterTitle": "Títol del Capítol", + "LabelChapters": "Capítols", + "LabelChaptersFound": "Capítol Trobat", + "LabelClickForMoreInfo": "Fes clic per a més informació", + "LabelClickToUseCurrentValue": "Fes clic per utilitzar el valor actual", + "LabelClosePlayer": "Tancar reproductor", + "LabelCodec": "Còdec", + "LabelCollapseSeries": "Contraure sèrie", + "LabelCollapseSubSeries": "Contraure la subsèrie", + "LabelCollection": "Col·lecció", + "LabelCollections": "Col·leccions", + "LabelComplete": "Complet", + "LabelConfirmPassword": "Confirmar Contrasenya", + "LabelContinueListening": "Continuar escoltant", + "LabelContinueReading": "Continuar llegint", + "LabelContinueSeries": "Continuar sèries", + "LabelCover": "Portada", + "LabelCoverImageURL": "URL de la Imatge de Portada", + "LabelCreatedAt": "Creat", + "LabelCronExpression": "Expressió de Cron", + "LabelCurrent": "Actual", + "LabelCurrently": "En aquest moment:", + "LabelCustomCronExpression": "Expressió de Cron Personalitzada:", + "LabelDatetime": "Hora i Data", + "LabelDays": "Dies", + "LabelDeleteFromFileSystemCheckbox": "Eliminar arxius del sistema (desmarcar per eliminar només de la base de dades)", + "LabelDescription": "Descripció", + "LabelDeselectAll": "Desseleccionar Tots", + "LabelDevice": "Dispositiu", + "LabelDeviceInfo": "Informació del Dispositiu", + "LabelDeviceIsAvailableTo": "El dispositiu està disponible per a...", + "LabelDirectory": "Directori", + "LabelDiscFromFilename": "Disc a partir del Nom de l'Arxiu", + "LabelDiscFromMetadata": "Disc a partir de Metadades", + "LabelDiscover": "Descobrir", + "LabelDownload": "Descarregar", + "LabelDownloadNEpisodes": "Descarregar {0} episodis", + "LabelDuration": "Duració", + "LabelDurationComparisonExactMatch": "(coincidència exacta)", + "LabelDurationComparisonLonger": "({0} més llarg)", + "LabelDurationComparisonShorter": "({0} més curt)", + "LabelDurationFound": "Duració Trobada:", + "LabelEbook": "Llibre electrònic", + "LabelEbooks": "Llibres electrònics", + "LabelEdit": "Editar", + "LabelEmail": "Correu electrònic", + "LabelEmailSettingsFromAddress": "Remitent", + "LabelEmailSettingsRejectUnauthorized": "Rebutja certificats no autoritzats", + "LabelEmailSettingsRejectUnauthorizedHelp": "Desactivar la validació de certificats SSL pot exposar la teva connexió a riscos de seguretat, com atacs de tipus man-in-the-middle. Desactiva aquesta opció només si coneixes les implicacions i confies en el servidor de correu al qual et connectes.", + "LabelEmailSettingsSecure": "Seguretat", + "LabelEmailSettingsSecureHelp": "Si està activat, es farà servir TLS per connectar-se al servidor. Si està desactivat, es farà servir TLS si el servidor admet l'extensió STARTTLS. En la majoria dels casos, pots deixar aquesta opció activada si et connectes al port 465. Desactiva-la en el cas d'usar els ports 587 o 25. (de nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Provar Adreça", + "LabelEmbeddedCover": "Portada Integrada", + "LabelEnable": "Habilitar", + "LabelEncodingBackupLocation": "Es guardarà una còpia de seguretat dels teus arxius d'àudio originals a:", + "LabelEncodingChaptersNotEmbedded": "Els capítols no s'incrusten en els audiollibres multipista.", + "LabelEncodingClearItemCache": "Assegura't de purgar periòdicament la memòria cau.", + "LabelEncodingFinishedM4B": "El M4B acabat es col·locarà a la teva carpeta d'audiollibres a:", + "LabelEncodingInfoEmbedded": "Les metadades s'integraran a les pistes d'àudio dins de la carpeta d'audiollibres.", + "LabelEncodingStartedNavigation": "Un cop iniciada la tasca, pots sortir d'aquesta pàgina.", + "LabelEncodingTimeWarning": "La codificació pot trigar fins a 30 minuts.", + "LabelEncodingWarningAdvancedSettings": "Advertència: No actualitzis aquesta configuració tret que estiguis familiaritzat amb les opcions de codificació d'ffmpeg.", + "LabelEncodingWatcherDisabled": "Si has desactivat la supervisió dels arxius, hauràs de tornar a escanejar aquest audiollibre més endavant.", + "LabelEnd": "Fi", + "LabelEndOfChapter": "Fi del capítol", + "LabelEpisode": "Episodi", + "LabelEpisodeNotLinkedToRssFeed": "Episodi no enllaçat al feed RSS", + "LabelEpisodeNumber": "Episodi #{0}", + "LabelEpisodeTitle": "Títol de l'Episodi", + "LabelEpisodeType": "Tipus d'Episodi", + "LabelEpisodeUrlFromRssFeed": "URL de l'episodi del feed RSS", + "LabelEpisodes": "Episodis", + "LabelEpisodic": "Episodis", + "LabelExample": "Exemple", + "LabelExpandSeries": "Ampliar sèrie", + "LabelExpandSubSeries": "Expandir la subsèrie", + "LabelExplicit": "Explícit", + "LabelExplicitChecked": "Explícit (marcat)", + "LabelExplicitUnchecked": "No Explícit (sense marcar)", + "LabelExportOPML": "Exportar OPML", + "LabelFeedURL": "Font de URL", + "LabelFetchingMetadata": "Obtenció de metadades", + "LabelFile": "Arxiu", + "LabelFileBirthtime": "Arxiu creat a", + "LabelFileBornDate": "Creat {0}", + "LabelFileModified": "Arxiu modificat", + "LabelFileModifiedDate": "Modificat {0}", + "LabelFilename": "Nom de l'arxiu", + "LabelFilterByUser": "Filtrar per Usuari", + "LabelFindEpisodes": "Cercar Episodi", + "LabelFinished": "Acabat", + "LabelFolder": "Carpeta", + "LabelFolders": "Carpetes", + "LabelFontBold": "Negreta", + "LabelFontBoldness": "Nivell de negreta en font", + "LabelFontFamily": "Família tipogràfica", + "LabelFontItalic": "Cursiva", + "LabelFontScale": "Mida de la font", + "LabelFontStrikethrough": "Ratllat", + "LabelFormat": "Format", + "LabelFull": "Complet", + "LabelGenre": "Gènere", + "LabelGenres": "Gèneres", + "LabelHardDeleteFile": "Eliminar Definitivament", + "LabelHasEbook": "Té un llibre electrònic", + "LabelHasSupplementaryEbook": "Té un llibre electrònic complementari", + "LabelHideSubtitles": "Amagar subtítols", + "LabelHighestPriority": "Prioritat més alta", + "LabelHost": "Amfitrió", + "LabelHour": "Hora", + "LabelHours": "Hores", + "LabelIcon": "Icona", + "LabelImageURLFromTheWeb": "URL de la imatge", + "LabelInProgress": "En procés", + "LabelIncludeInTracklist": "Incloure a la Llista de Pistes", + "LabelIncomplete": "Incomplet", + "LabelInterval": "Interval", + "LabelIntervalCustomDailyWeekly": "Personalitzar diari/setmanal", + "LabelIntervalEvery12Hours": "Cada 12 Hores", + "LabelIntervalEvery15Minutes": "Cada 15 minuts", + "LabelIntervalEvery2Hours": "Cada 2 Hores", + "LabelIntervalEvery30Minutes": "Cada 30 minuts", + "LabelIntervalEvery6Hours": "Cada 6 Hores", + "LabelIntervalEveryDay": "Cada Dia", + "LabelIntervalEveryHour": "Cada Hora", + "LabelInvert": "Invertir", + "LabelItem": "Element", + "LabelJumpBackwardAmount": "Quantitat de salts cap enrere", + "LabelJumpForwardAmount": "Quantitat de salts cap endavant", + "LabelLanguage": "Idioma", + "LabelLanguageDefaultServer": "Idioma Predeterminat del Servidor", + "LabelLanguages": "Idiomes", + "LabelLastBookAdded": "Últim Llibre Afegit", + "LabelLastBookUpdated": "Últim Llibre Actualitzat", + "LabelLastSeen": "Última Vegada Vist", + "LabelLastTime": "Última Vegada", + "LabelLastUpdate": "Última Actualització", + "LabelLayout": "Distribució", + "LabelLayoutSinglePage": "Pàgina única", + "LabelLayoutSplitPage": "Dues Pàgines", + "LabelLess": "Menys", + "LabelLibrariesAccessibleToUser": "Biblioteques Disponibles per a l'Usuari", + "LabelLibrary": "Biblioteca", + "LabelLibraryFilterSublistEmpty": "Sense {0}", + "LabelLibraryItem": "Element de Biblioteca", + "LabelLibraryName": "Nom de Biblioteca", + "LabelLimit": "Límits", + "LabelLineSpacing": "Interlineat", + "LabelListenAgain": "Escoltar de nou", + "LabelLogLevelDebug": "Depurar", + "LabelLogLevelInfo": "Informació", + "LabelLogLevelWarn": "Advertència", + "LabelLookForNewEpisodesAfterDate": "Cercar nous episodis a partir d'aquesta data", + "LabelLowestPriority": "Menor prioritat", + "LabelMatchExistingUsersBy": "Emparellar els usuaris existents per", + "LabelMatchExistingUsersByDescription": "S'utilitza per connectar usuaris existents. Un cop connectats, els usuaris seran emparellats per un identificador únic del seu proveïdor de SSO", + "LabelMaxEpisodesToDownload": "Nombre màxim d'episodis per descarregar. Usa 0 per descarregar una quantitat il·limitada.", + "LabelMaxEpisodesToDownloadPerCheck": "Nombre màxim de nous episodis que es descarregaran per comprovació", + "LabelMaxEpisodesToKeep": "Nombre màxim d'episodis que es mantindran", + "LabelMaxEpisodesToKeepHelp": "El valor 0 no estableix un límit màxim. Després de descarregar automàticament un nou episodi, això eliminarà l'episodi més antic si té més de X episodis. Això només eliminarà 1 episodi per nova descàrrega.", + "LabelMediaPlayer": "Reproductor de Mitjans", + "LabelMediaType": "Tipus de multimèdia", + "LabelMetaTag": "Metaetiqueta", + "LabelMetaTags": "Metaetiquetes", + "LabelMetadataOrderOfPrecedenceDescription": "Les fonts de metadades de major prioritat prevaldran sobre les de menor prioritat", + "LabelMetadataProvider": "Proveïdor de Metadades", + "LabelMinute": "Minut", + "LabelMinutes": "Minuts", + "LabelMissing": "Absent", + "LabelMissingEbook": "No té ebook", + "LabelMissingSupplementaryEbook": "No té ebook complementari", + "LabelMobileRedirectURIs": "URI de redirecció mòbil permeses", + "LabelMobileRedirectURIsDescription": "Aquesta és una llista blanca d'URI de redirecció vàlides per a aplicacions mòbils. El predeterminat és audiobookshelf, que pots eliminar o complementar amb URI addicionals per a la integració d'aplicacions de tercers. Usant un asterisc ( *) com a única entrada que permet qualsevol URI.", + "LabelMore": "Més", + "LabelMoreInfo": "Més informació", + "LabelName": "Nom", + "LabelNarrator": "Narrador", + "LabelNarrators": "Narradors", + "LabelNew": "Nou", + "LabelNewPassword": "Nova Contrasenya", + "LabelNewestAuthors": "Autors més recents", + "LabelNewestEpisodes": "Episodis més recents", + "LabelNextBackupDate": "Data del Següent Respatller", + "LabelNextScheduledRun": "Proper Execució Programada", + "LabelNoCustomMetadataProviders": "Sense proveïdors de metadades personalitzats", + "LabelNoEpisodesSelected": "Cap Episodi Seleccionat", + "LabelNotFinished": "No acabat", + "LabelNotStarted": "Sense iniciar", + "LabelNotes": "Notes", + "LabelNotificationAppriseURL": "URL(s) d'Apprise", + "LabelNotificationAvailableVariables": "Variables Disponibles", + "LabelNotificationBodyTemplate": "Plantilla de Cos", + "LabelNotificationEvent": "Esdeveniment de Notificació", + "LabelNotificationTitleTemplate": "Plantilla de Títol", + "LabelNotificationsMaxFailedAttempts": "Màxim d'Intents Fallits", + "LabelNotificationsMaxFailedAttemptsHelp": "Les notificacions es desactivaran després de fallar aquest nombre de vegades", + "LabelNotificationsMaxQueueSize": "Mida màxima de la cua de notificacions", + "LabelNotificationsMaxQueueSizeHelp": "Les notificacions estan limitades a 1 per segon. Les notificacions seran ignorades si arriben al número màxim de cua per prevenir spam d'esdeveniments.", + "LabelNumberOfBooks": "Nombre de Llibres", + "LabelNumberOfEpisodes": "Nombre d'Episodis", + "LabelOpenIDAdvancedPermsClaimDescription": "Nom de la notificació de OpenID que conté permisos avançats per accions d'usuari dins l'aplicació que s'aplicaran a rols que no siguin d'administrador (si estan configurats). Si el reclam no apareix en la resposta, es denegarà l'accés a ABS. Si manca una sola opció, es tractarà com a falsa. Assegura't que la notificació del proveïdor d'identitats coincideixi amb l'estructura esperada:", + "LabelOpenIDClaims": "Deixa les següents opcions buides per desactivar l'assignació avançada de grups i permisos, el que assignaria automàticament al grup 'Usuari'.", + "LabelOpenIDGroupClaimDescription": "Nom de la declaració OpenID que conté una llista de grups de l'usuari. Comunament coneguts com grups. Si es configura, l'aplicació assignarà automàticament rols basats en la pertinença a grups de l'usuari, sempre que aquests grups es denominen 'admin', 'user' o 'guest' en la notificació. La sol·licitud ha de contenir una llista, i si un usuari pertany a diversos grups, l'aplicació assignarà el rol corresponent al major nivell d'accés. Si cap grup coincideix, es denegarà l'accés.", + "LabelOverwrite": "Sobreescriure", + "LabelPaginationPageXOfY": "Pàgina {0} de {1}", + "LabelPassword": "Contrasenya", + "LabelPath": "Ruta de carpeta", + "LabelPermanent": "Permanent", + "LabelPermissionsAccessAllLibraries": "Pot Accedir a Totes les Biblioteques", + "LabelPermissionsAccessAllTags": "Pot Accedir a Totes les Etiquetes", + "LabelPermissionsAccessExplicitContent": "Pot Accedir a Contingut Explícit", + "LabelPermissionsCreateEreader": "Pot Crear un Gestor de Projectes", + "LabelPermissionsDelete": "Pot Eliminar", + "LabelPermissionsDownload": "Pot Descarregar", + "LabelPermissionsUpdate": "Pot Actualitzar", + "LabelPermissionsUpload": "Pot Pujar", + "LabelPersonalYearReview": "Revisió del teu any ({0})", + "LabelPhotoPathURL": "Ruta/URL de la Foto", + "LabelPlayMethod": "Mètode de Reproducció", + "LabelPlayerChapterNumberMarker": "{0} de {1}", + "LabelPlaylists": "Llistes de Reproducció", + "LabelPodcast": "Podcast", + "LabelPodcastSearchRegion": "Regió de Cerca de Podcasts", + "LabelPodcastType": "Tipus de Podcast", + "LabelPodcasts": "Podcasts", + "LabelPort": "Port", + "LabelPrefixesToIgnore": "Prefixos per Ignorar (no distingeix entre majúscules i minúscules.)", + "LabelPreventIndexing": "Evita que la teva font sigui indexada pels directoris de podcasts d'iTunes i Google", + "LabelPrimaryEbook": "Ebook Principal", + "LabelProgress": "Progrés", + "LabelProvider": "Proveïdor", + "LabelProviderAuthorizationValue": "Valor de l'encapçalament d'autorització", + "LabelPubDate": "Data de Publicació", + "LabelPublishYear": "Any de Publicació", + "LabelPublishedDate": "Publicat {0}", + "LabelPublishedDecade": "Dècada de Publicació", + "LabelPublishedDecades": "Dècades Publicades", + "LabelPublisher": "Editor", + "LabelPublishers": "Editors", + "LabelRSSFeedCustomOwnerEmail": "Correu Electrònic Personalitzat del Propietari", + "LabelRSSFeedCustomOwnerName": "Nom Personalitzat del Propietari", + "LabelRSSFeedOpen": "Font RSS Oberta", + "LabelRSSFeedPreventIndexing": "Evitar l'indexació", + "LabelRSSFeedSlug": "Font RSS Slug", + "LabelRSSFeedURL": "URL de la Font RSS", + "LabelRandomly": "Aleatòriament", + "LabelReAddSeriesToContinueListening": "Reafegir la sèrie per continuar escoltant-la", + "LabelRead": "Llegit", + "LabelReadAgain": "Tornar a llegir", + "LabelReadEbookWithoutProgress": "Llegir Ebook sense guardar progrés", + "LabelRecentSeries": "Sèries Recents", + "LabelRecentlyAdded": "Afegit Recentment", + "LabelRecommended": "Recomanats", + "LabelRedo": "Refer", + "LabelRegion": "Regió", + "LabelReleaseDate": "Data d'Estrena", + "LabelRemoveAllMetadataAbs": "Eliminar tots els fitxers metadata.abs", + "LabelRemoveAllMetadataJson": "Eliminar tots els fitxers metadata.json", + "LabelRemoveCover": "Eliminar Coberta", + "LabelRemoveMetadataFile": "Eliminar fitxers de metadades en carpetes d'elements de biblioteca", + "LabelRemoveMetadataFileHelp": "Elimina tots els fitxers metadata.json i metadata.abs de les teves carpetes {0}.", + "LabelRowsPerPage": "Files per Pàgina", + "LabelSearchTerm": "Cercar Terme", + "LabelSearchTitle": "Cercar Títol", + "LabelSearchTitleOrASIN": "Cercar Títol o ASIN", + "LabelSeason": "Temporada", + "LabelSeasonNumber": "Temporada #{0}", + "LabelSelectAll": "Seleccionar tot", + "LabelSelectAllEpisodes": "Seleccionar tots els episodis", + "LabelSelectEpisodesShowing": "Seleccionar els {0} episodis visibles", + "LabelSelectUsers": "Seleccionar usuaris", + "LabelSendEbookToDevice": "Enviar Ebook a...", + "LabelSequence": "Seqüència", + "LabelSerial": "Serial", + "LabelSeries": "Sèries", + "LabelSeriesName": "Nom de la Sèrie", + "LabelSeriesProgress": "Progrés de la Sèrie", + "LabelServerLogLevel": "Nivell de registre del servidor", + "LabelServerYearReview": "Resum de l'any del servidor ({0})", + "LabelSetEbookAsPrimary": "Establir com a principal", + "LabelSetEbookAsSupplementary": "Establir com a suplementari", + "LabelSettingsAudiobooksOnly": "Només Audiollibres", + "LabelSettingsAudiobooksOnlyHelp": "Activant aquesta opció s'ignoraran els fitxers d'ebook, excepte si estan dins d'una carpeta d'audiollibre, en aquest cas es marcaran com ebooks suplementaris", + "LabelSettingsBookshelfViewHelp": "Disseny esqueomorf amb prestatgeries de fusta", + "LabelSettingsChromecastSupport": "Compatibilitat amb Chromecast", + "LabelSettingsDateFormat": "Format de Data", + "LabelSettingsDisableWatcher": "Desactivar Watcher", + "LabelSettingsDisableWatcherForLibrary": "Desactivar Watcher de Carpetes per a aquesta biblioteca", + "LabelSettingsDisableWatcherHelp": "Desactiva la funció d'afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor", + "LabelSettingsEnableWatcher": "Habilitar Watcher", + "LabelSettingsEnableWatcherForLibrary": "Habilitar Watcher per a la carpeta d'aquesta biblioteca", + "LabelSettingsEnableWatcherHelp": "Permet afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor", + "LabelSettingsEpubsAllowScriptedContent": "Permetre scripts en epubs", + "LabelSettingsEpubsAllowScriptedContentHelp": "Permetre que els fitxers epub executin scripts. Es recomana mantenir aquesta opció desactivada tret que confiïs en l'origen dels fitxers epub.", + "LabelSettingsExperimentalFeatures": "Funcions Experimentals", + "LabelSettingsExperimentalFeaturesHelp": "Funcions en desenvolupament que es beneficiarien dels teus comentaris i experiències de prova. Feu clic aquí per obrir una conversa a Github.", + "LabelShowAll": "Mostra-ho tot", + "LabelShowSeconds": "Mostra segons", + "LabelShowSubtitles": "Mostra subtítols", + "LabelSize": "Mida", + "LabelSleepTimer": "Temporitzador de repòs", + "LabelSlug": "Slug", + "LabelStart": "Inicia", + "LabelStartTime": "Hora d'inici", + "LabelStarted": "Iniciat", + "LabelStartedAt": "Iniciat a", + "LabelStatsAudioTracks": "Pistes d'àudio", + "LabelStatsAuthors": "Autors", + "LabelStatsBestDay": "Millor dia", + "LabelStatsDailyAverage": "Mitjana diària", + "LabelStatsDays": "Dies", + "LabelStatsDaysListened": "Dies escoltats", + "LabelStatsHours": "Hores", + "LabelStatsInARow": "seguits", + "LabelStatsItemsFinished": "Elements acabats", + "LabelStatsItemsInLibrary": "Elements a la biblioteca", + "LabelStatsMinutes": "minuts", + "LabelStatsMinutesListening": "Minuts escoltant", + "LabelStatsOverallDays": "Total de dies", + "LabelStatsOverallHours": "Total d'hores", + "LabelStatsWeekListening": "Temps escoltat aquesta setmana", + "LabelSubtitle": "Subtítol", + "LabelSupportedFileTypes": "Tipus de fitxers compatibles", + "LabelTag": "Etiqueta", + "LabelTags": "Etiquetes", + "LabelTagsAccessibleToUser": "Etiquetes accessibles per a l'usuari", + "LabelTagsNotAccessibleToUser": "Etiquetes no accessibles per a l'usuari", + "LabelTasks": "Tasques en execució", + "LabelTextEditorBulletedList": "Llista amb punts", + "LabelTextEditorLink": "Enllaça", + "LabelTextEditorNumberedList": "Llista numerada", + "LabelTextEditorUnlink": "Desenllaça", + "LabelTheme": "Tema", + "LabelThemeDark": "Fosc", + "LabelThemeLight": "Clar", + "LabelTimeBase": "Temps base", + "LabelTimeDurationXHours": "{0} hores", + "LabelTimeDurationXMinutes": "{0} minuts", + "LabelTimeDurationXSeconds": "{0} segons", + "LabelTimeInMinutes": "Temps en minuts", + "LabelTimeLeft": "Queden {0}", + "LabelTimeListened": "Temps escoltat", + "LabelTimeListenedToday": "Temps escoltat avui", + "LabelTimeRemaining": "{0} restant", + "LabelTimeToShift": "Temps per canviar en segons", + "LabelTitle": "Títol", + "LabelToolsEmbedMetadata": "Incrusta metadades", + "LabelToolsEmbedMetadataDescription": "Incrusta metadades en els fitxers d'àudio, incloent la portada i capítols.", + "LabelToolsM4bEncoder": "Codificador M4B", + "LabelToolsMakeM4b": "Crea fitxer d'audiollibre M4B", + "LabelToolsMakeM4bDescription": "Genera un fitxer d'audiollibre .M4B amb metadades, imatges de portada i capítols incrustats.", + "LabelToolsSplitM4b": "Divideix M4B en fitxers MP3", + "LabelToolsSplitM4bDescription": "Divideix un M4B en fitxers MP3 i incrusta metadades, imatges de portada i capítols.", + "LabelTotalDuration": "Duració total", + "LabelTotalTimeListened": "Temps total escoltat", + "LabelTrackFromFilename": "Pista des del nom del fitxer", + "LabelTrackFromMetadata": "Pista des de metadades", + "LabelTracks": "Pistes", + "LabelTracksMultiTrack": "Diverses pistes", + "LabelTracksNone": "Cap pista", + "LabelTracksSingleTrack": "Una pista", + "LabelTrailer": "Tràiler", + "LabelType": "Tipus", + "LabelUnabridged": "No abreujat", + "LabelUndo": "Desfés", + "LabelUnknown": "Desconegut", + "LabelUnknownPublishDate": "Data de publicació desconeguda", + "LabelUpdateCover": "Actualitza portada", + "LabelUpdateCoverHelp": "Permet sobreescriure les portades existents dels llibres seleccionats quan es trobi una coincidència.", + "LabelUpdateDetails": "Actualitza detalls", + "LabelUpdateDetailsHelp": "Permet sobreescriure els detalls existents dels llibres seleccionats quan es trobin coincidències.", + "LabelUpdatedAt": "Actualitzat a", + "LabelUploaderDragAndDrop": "Arrossega i deixa anar fitxers o carpetes", + "LabelUploaderDragAndDropFilesOnly": "Arrossega i deixa anar fitxers", + "LabelUploaderDropFiles": "Deixa anar els fitxers", + "LabelUploaderItemFetchMetadataHelp": "Cerca títol, autor i sèries automàticament", + "LabelUseAdvancedOptions": "Utilitza opcions avançades", + "LabelUseChapterTrack": "Utilitza pista per capítol", + "LabelUseFullTrack": "Utilitza pista completa", + "LabelUseZeroForUnlimited": "Utilitza 0 per il·limitat", + "LabelUser": "Usuari", + "LabelUsername": "Nom d'usuari", + "LabelValue": "Valor", + "LabelVersion": "Versió", + "LabelViewBookmarks": "Mostra marcadors", + "LabelViewChapters": "Mostra capítols", + "LabelViewPlayerSettings": "Mostra els ajustaments del reproductor", + "LabelViewQueue": "Mostra cua del reproductor", + "LabelVolume": "Volum", + "LabelWebRedirectURLsDescription": "Autoritza aquestes URL al teu proveïdor OAuth per permetre redirecció a l'aplicació web després d'iniciar sessió:", + "LabelWebRedirectURLsSubfolder": "Subcarpeta per a URL de redirecció", + "LabelWeekdaysToRun": "Executar en dies de la setmana", + "LabelXBooks": "{0} llibres", + "LabelXItems": "{0} elements", + "LabelYearReviewHide": "Oculta resum de l'any", + "LabelYearReviewShow": "Mostra resum de l'any", + "LabelYourAudiobookDuration": "Duració del teu audiollibre", + "LabelYourBookmarks": "Els teus marcadors", + "LabelYourPlaylists": "Les teves llistes", + "LabelYourProgress": "El teu progrés", + "MessageAddToPlayerQueue": "Afegeix a la cua del reproductor", + "MessageAppriseDescription": "Per utilitzar aquesta funció, hauràs de tenir l'API d'Apprise en funcionament o una API que gestioni resultats similars.
La URL de l'API d'Apprise ha de tenir la mateixa ruta d'arxius que on s'envien les notificacions. Per exemple: si la teva API és a http://192.168.1.1:8337, llavors posaries http://192.168.1.1:8337/notify.", + "MessageBackupsDescription": "Les còpies de seguretat inclouen: usuaris, progrés dels usuaris, detalls dels elements de la biblioteca, configuració del servidor i imatges a /metadata/items i /metadata/authors. Les còpies de seguretat NO inclouen cap fitxer guardat a la carpeta de la teva biblioteca.", + "MessageBackupsLocationEditNote": "Nota: Actualitzar la ubicació de la còpia de seguretat no mourà ni modificarà les còpies existents", + "MessageBackupsLocationNoEditNote": "Nota: La ubicació de la còpia de seguretat es defineix mitjançant una variable d'entorn i no es pot modificar aquí.", + "MessageBackupsLocationPathEmpty": "La ruta de la còpia de seguretat no pot estar buida", + "MessageBatchQuickMatchDescription": "La funció \"Troba Ràpid\" intentarà afegir portades i metadades que falten als elements seleccionats. Activa l'opció següent perquè \"Troba Ràpid\" pugui sobreescriure portades i/o metadades existents.", + "MessageBookshelfNoCollections": "No tens cap col·lecció", + "MessageBookshelfNoRSSFeeds": "Cap font RSS està oberta", + "MessageBookshelfNoResultsForFilter": "Cap resultat per al filtre \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Cap resultat per a la consulta", + "MessageBookshelfNoSeries": "No tens cap sèrie", + "MessageChapterEndIsAfter": "El final del capítol és després del final del teu audiollibre", + "MessageChapterErrorFirstNotZero": "El primer capítol ha de començar a 0", + "MessageChapterErrorStartGteDuration": "El temps d'inici no és vàlid: ha de ser inferior a la durada de l'audiollibre", + "MessageChapterErrorStartLtPrev": "El temps d'inici no és vàlid: ha de ser igual o més gran que el temps d'inici del capítol anterior", + "MessageChapterStartIsAfter": "L'inici del capítol és després del final del teu audiollibre", + "MessageCheckingCron": "Comprovant cron...", + "MessageConfirmCloseFeed": "Estàs segur que vols tancar aquesta font?", + "MessageConfirmDeleteBackup": "Estàs segur que vols eliminar la còpia de seguretat {0}?", + "MessageConfirmDeleteDevice": "Estàs segur que vols eliminar el lector electrònic \"{0}\"?", + "MessageConfirmDeleteFile": "Això eliminarà el fitxer del teu sistema. Estàs segur?", + "MessageConfirmDeleteLibrary": "Estàs segur que vols eliminar permanentment la biblioteca \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "Això eliminarà l'element de la base de dades i del sistema. Estàs segur?", + "MessageConfirmDeleteLibraryItems": "Això eliminarà {0} element(s) de la base de dades i del sistema. Estàs segur?", + "MessageConfirmDeleteMetadataProvider": "Estàs segur que vols eliminar el proveïdor de metadades personalitzat \"{0}\"?", + "MessageConfirmDeleteNotification": "Estàs segur que vols eliminar aquesta notificació?", + "MessageConfirmDeleteSession": "Estàs segur que vols eliminar aquesta sessió?", + "MessageConfirmEmbedMetadataInAudioFiles": "Estàs segur que vols incrustar metadades a {0} fitxer(s) d'àudio?", + "MessageConfirmForceReScan": "Estàs segur que vols forçar un reescaneig?", + "MessageConfirmMarkAllEpisodesFinished": "Estàs segur que vols marcar tots els episodis com a acabats?", + "MessageConfirmMarkAllEpisodesNotFinished": "Estàs segur que vols marcar tots els episodis com a no acabats?", + "MessageConfirmMarkItemFinished": "Estàs segur que vols marcar \"{0}\" com a acabat?", + "MessageConfirmMarkItemNotFinished": "Estàs segur que vols marcar \"{0}\" com a no acabat?", + "MessageConfirmMarkSeriesFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a acabats?", + "MessageConfirmMarkSeriesNotFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a no acabats?", + "MessageConfirmNotificationTestTrigger": "Vols activar aquesta notificació amb dades de prova?", + "MessageConfirmPurgeCache": "Esborrar la memòria cau eliminarà tot el directori localitzat a /metadata/cache.

Estàs segur que vols eliminar-lo?", + "MessageConfirmPurgeItemsCache": "Esborrar la memòria cau dels elements eliminarà el directori /metadata/cache/items.
Estàs segur?", + "MessageConfirmQuickEmbed": "Advertència! La integració ràpida no fa còpies de seguretat dels teus fitxers d'àudio. Assegura't d'haver-ne fet una còpia abans.

Vols continuar?", + "MessageConfirmQuickMatchEpisodes": "El reconeixement ràpid sobreescriurà els detalls si es troba una coincidència. Estàs segur?", + "MessageConfirmReScanLibraryItems": "Estàs segur que vols reescanejar {0} element(s)?", + "MessageConfirmRemoveAllChapters": "Estàs segur que vols eliminar tots els capítols?", + "MessageConfirmRemoveAuthor": "Estàs segur que vols eliminar l'autor \"{0}\"?", + "MessageConfirmRemoveCollection": "Estàs segur que vols eliminar la col·lecció \"{0}\"?", + "MessageConfirmRemoveEpisode": "Estàs segur que vols eliminar l'episodi \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Estàs segur que vols eliminar {0} episodis?", + "MessageConfirmRemoveListeningSessions": "Estàs segur que vols eliminar {0} sessions d'escolta?", + "MessageConfirmRemoveMetadataFiles": "Estàs segur que vols eliminar tots els fitxers de metadades.{0} de les carpetes dels elements de la teva biblioteca?", + "MessageConfirmRemoveNarrator": "Estàs segur que vols eliminar el narrador \"{0}\"?", + "MessageConfirmRemovePlaylist": "Estàs segur que vols eliminar la llista de reproducció \"{0}\"?", + "MessageConfirmRenameGenre": "Estàs segur que vols canviar el gènere \"{0}\" a \"{1}\" per a tots els elements?", + "MessageConfirmRenameGenreMergeNote": "Nota: Aquest gènere ja existeix, i es fusionarà.", + "MessageConfirmRenameGenreWarning": "Advertència! Ja existeix un gènere similar \"{0}\".", + "MessageConfirmRenameTag": "Estàs segur que vols canviar l'etiqueta \"{0}\" a \"{1}\" per a tots els elements?", + "MessageConfirmRenameTagMergeNote": "Nota: Aquesta etiqueta ja existeix, i es fusionarà.", + "MessageConfirmRenameTagWarning": "Advertència! Ja existeix una etiqueta similar \"{0}\".", + "MessageConfirmResetProgress": "Estàs segur que vols reiniciar el teu progrés?", + "MessageConfirmSendEbookToDevice": "Estàs segur que vols enviar {0} ebook(s) \"{1}\" al dispositiu \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Estàs segur que vols desvincular aquest usuari d'OpenID?", + "MessageDownloadingEpisode": "Descarregant capítol", + "MessageDragFilesIntoTrackOrder": "Arrossega els fitxers en l'ordre correcte de les pistes", + "MessageEmbedFailed": "Error en incrustar!", + "MessageEmbedFinished": "Incrustació acabada!", + "MessageEmbedQueue": "En cua per incrustar metadades ({0} en cua)", + "MessageMarkAsFinished": "Marcar com acabat", + "MessageMarkAsNotFinished": "Marcar com no acabat", + "MessageMatchBooksDescription": "S'intentarà fer coincidir els llibres de la biblioteca amb un llibre del proveïdor de cerca seleccionat, i s'ompliran els detalls buits i la portada. No sobreescriu els detalls.", + "MessageNoAudioTracks": "Sense pistes d'àudio", + "MessageNoAuthors": "Sense autors", + "MessageNoBackups": "Sense còpies de seguretat", + "MessageNoBookmarks": "Sense marcadors", + "MessageNoChapters": "Sense capítols", + "MessageNoCollections": "Sense col·leccions", + "MessageNoCoversFound": "Cap portada trobada", + "MessageNoDescription": "Sense descripció", + "MessageNoDevices": "Sense dispositius", + "MessageNoDownloadsInProgress": "No hi ha descàrregues en curs", + "MessageNoDownloadsQueued": "Sense cua de descàrrega", + "MessageNoEpisodeMatchesFound": "No s'han trobat episodis que coincideixin", + "MessageNoEpisodes": "Sense episodis", + "MessageNoFoldersAvailable": "No hi ha carpetes disponibles", + "MessageNoGenres": "Sense gèneres", + "MessageNoIssues": "Sense problemes", + "MessageNoItems": "Sense elements", + "MessageNoItemsFound": "Cap element trobat", + "MessageNoListeningSessions": "Sense sessions escoltades", + "MessageNoLogs": "Sense registres", + "MessageNoMediaProgress": "Sense progrés multimèdia", + "MessageNoNotifications": "Sense notificacions", + "MessageNoPodcastFeed": "Podcast no vàlid: sense font", + "MessageNoPodcastsFound": "Cap podcast trobat", + "MessageNoResults": "Sense resultats", + "MessageNoSearchResultsFor": "No hi ha resultats per a la cerca \"{0}\"", + "MessageNoSeries": "Sense sèries", + "MessageNoTags": "Sense etiquetes", + "MessageNoTasksRunning": "Sense tasques en execució", + "MessageNoUpdatesWereNecessary": "No calien actualitzacions", + "MessageNoUserPlaylists": "No tens cap llista de reproducció", + "MessageNotYetImplemented": "Encara no implementat", + "MessageOpmlPreviewNote": "Nota: Aquesta és una vista prèvia de l'arxiu OPML analitzat. El títol real del podcast s'obtindrà del canal RSS.", + "MessageOr": "o", + "MessagePauseChapter": "Pausar la reproducció del capítol", + "MessagePlayChapter": "Escoltar l'inici del capítol", + "MessagePlaylistCreateFromCollection": "Crear una llista de reproducció a partir d'una col·lecció", + "MessagePleaseWait": "Espera si us plau...", + "MessagePodcastHasNoRSSFeedForMatching": "El podcast no té una URL de font RSS que es pugui utilitzar", + "MessagePodcastSearchField": "Introdueix el terme de cerca o la URL de la font RSS", + "MessageQuickEmbedInProgress": "Integració ràpida en procés", + "MessageQuickEmbedQueue": "En cua per a inserció ràpida ({0} en cua)", + "MessageQuickMatchAllEpisodes": "Combina ràpidament tots els episodis", + "MessageQuickMatchDescription": "Omple detalls d'elements buits i portades amb els primers resultats de '{0}'. No sobreescriu els detalls tret que l'opció \"Preferir metadades trobades\" del servidor estigui habilitada.", + "MessageRemoveChapter": "Eliminar capítols", + "MessageRemoveEpisodes": "Eliminar {0} episodi(s)", + "MessageRemoveFromPlayerQueue": "Eliminar de la cua del reproductor", + "MessageRemoveUserWarning": "Estàs segur que vols eliminar l'usuari \"{0}\"?", + "MessageReportBugsAndContribute": "Informa d'errors, sol·licita funcions i contribueix a", + "MessageResetChaptersConfirm": "Estàs segur que vols desfer els canvis i revertir els capítols al seu estat original?", + "MessageRestoreBackupConfirm": "Estàs segur que vols restaurar la còpia de seguretat creada a", + "MessageRestoreBackupWarning": "Restaurar sobreescriurà tota la base de dades situada a /config i les imatges de portades a /metadata/items i /metadata/authors.

La còpia de seguretat no modifica cap fitxer a les carpetes de la teva biblioteca. Si has activat l'opció del servidor per guardar portades i metadades a les carpetes de la biblioteca, aquests fitxers no es guarden ni sobreescriuen.

Tots els clients que utilitzin el teu servidor s'actualitzaran automàticament.", + "MessageSearchResultsFor": "Resultats de la cerca de", + "MessageSelected": "{0} seleccionat(s)", + "MessageServerCouldNotBeReached": "No es va poder establir la connexió amb el servidor", + "MessageSetChaptersFromTracksDescription": "Establir capítols utilitzant cada fitxer d'àudio com un capítol i el títol del capítol com el nom del fitxer d'àudio", + "MessageShareExpirationWillBe": "La caducitat serà {0}", + "MessageShareExpiresIn": "Caduca en {0}", + "MessageShareURLWillBe": "La URL per compartir serà {0}", + "MessageStartPlaybackAtTime": "Començar la reproducció per a \"{0}\" a {1}?", + "MessageTaskAudioFileNotWritable": "El fitxer d'àudio \"{0}\" no es pot escriure", + "MessageTaskCanceledByUser": "Tasca cancel·lada per l'usuari", + "MessageTaskDownloadingEpisodeDescription": "Descarregant l'episodi \"{0}\"", + "MessageTaskEmbeddingMetadata": "Inserint metadades", + "MessageTaskEmbeddingMetadataDescription": "Inserint metadades en l'audiollibre \"{0}\"", + "MessageTaskEncodingM4b": "Codificant M4B", + "MessageTaskEncodingM4bDescription": "Codificant l'audiollibre \"{0}\" en un únic fitxer M4B", + "MessageTaskFailed": "Fallada", + "MessageTaskFailedToBackupAudioFile": "Error en fer una còpia de seguretat del fitxer d'àudio \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Error en crear el directori de la memòria cau", + "MessageTaskFailedToEmbedMetadataInFile": "Error en incrustar metadades en el fitxer \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Error en fusionar fitxers d'àudio", + "MessageTaskFailedToMoveM4bFile": "Error en moure el fitxer M4B", + "MessageTaskFailedToWriteMetadataFile": "Error en escriure el fitxer de metadades", + "MessageTaskMatchingBooksInLibrary": "Coincidint llibres a la biblioteca \"{0}\"", + "MessageTaskNoFilesToScan": "Sense fitxers per escanejar", + "MessageTaskOpmlImport": "Importar OPML", + "MessageTaskOpmlImportDescription": "Creant podcasts a partir de {0} fonts RSS", + "MessageTaskOpmlImportFeed": "Importació de feed OPML", + "MessageTaskOpmlImportFeedDescription": "Importació del feed RSS \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "No es pot obtenir el podcast", + "MessageTaskOpmlImportFeedPodcastDescription": "Creant el podcast \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "El podcast ja existeix a la ruta", + "MessageTaskOpmlImportFeedPodcastFailed": "Error en crear el podcast", + "MessageTaskOpmlImportFinished": "Afegit {0} podcasts", + "MessageTaskOpmlParseFailed": "No s'ha pogut analitzar el fitxer OPML", + "MessageTaskOpmlParseFastFail": "No s'ha trobat l'etiqueta o al fitxer OPML", + "MessageTaskOpmlParseNoneFound": "No s'han trobat fonts al fitxer OPML", + "MessageTaskScanItemsAdded": "{0} afegit", + "MessageTaskScanItemsMissing": "{0} faltant", + "MessageTaskScanItemsUpdated": "{0} actualitzat", + "MessageTaskScanNoChangesNeeded": "No calen canvis", + "MessageTaskScanningFileChanges": "Escanejant canvis al fitxer en \"{0}\"", + "MessageTaskScanningLibrary": "Escanejant la biblioteca \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "El directori de destinació no es pot escriure", + "MessageThinking": "Pensant...", + "MessageUploaderItemFailed": "Error en pujar", + "MessageUploaderItemSuccess": "Pujada amb èxit!", + "MessageUploading": "Pujant...", + "MessageValidCronExpression": "Expressió de cron vàlida", + "MessageWatcherIsDisabledGlobally": "El watcher està desactivat globalment a la configuració del servidor", + "MessageXLibraryIsEmpty": "La biblioteca {0} està buida!", + "MessageYourAudiobookDurationIsLonger": "La durada del teu audiollibre és més llarga que la durada trobada", + "MessageYourAudiobookDurationIsShorter": "La durada del teu audiollibre és més curta que la durada trobada", + "NoteChangeRootPassword": "L'usuari Root és l'únic usuari que pot no tenir una contrasenya", + "NoteChapterEditorTimes": "Nota: El temps d'inici del primer capítol ha de romandre a 0:00, i el temps d'inici de l'últim capítol no pot superar la durada de l'audiollibre.", + "NoteFolderPicker": "Nota: Les carpetes ja assignades no es mostraran", + "NoteRSSFeedPodcastAppsHttps": "Advertència: La majoria d'aplicacions de podcast requereixen que la URL de la font RSS utilitzi HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Advertència: Un o més dels teus episodis no tenen data de publicació. Algunes aplicacions de podcast ho requereixen.", + "NoteUploaderFoldersWithMediaFiles": "Les carpetes amb fitxers multimèdia es gestionaran com a elements separats a la biblioteca.", + "NoteUploaderOnlyAudioFiles": "Si només puges fitxers d'àudio, cada fitxer es gestionarà com un audiollibre separat.", + "NoteUploaderUnsupportedFiles": "Els fitxers no compatibles seran ignorats. Si selecciona o arrossega una carpeta, els fitxers que no estiguin dins d'una subcarpeta seran ignorats.", + "NotificationOnBackupCompletedDescription": "S'activa quan es completa una còpia de seguretat", + "NotificationOnBackupFailedDescription": "S'activa quan falla una còpia de seguretat", + "NotificationOnEpisodeDownloadedDescription": "S'activa quan es descarrega automàticament un episodi d'un podcast", + "NotificationOnTestDescription": "Esdeveniment per provar el sistema de notificacions", + "PlaceholderNewCollection": "Nou nom de la col·lecció", + "PlaceholderNewFolderPath": "Nova ruta de carpeta", + "PlaceholderNewPlaylist": "Nou nom de la llista de reproducció", + "PlaceholderSearch": "Cerca...", + "PlaceholderSearchEpisode": "Cerca d'episodis...", + "StatsAuthorsAdded": "autors afegits", + "StatsBooksAdded": "llibres afegits", + "StatsBooksAdditional": "Algunes addicions inclouen…", + "StatsBooksFinished": "llibres acabats", + "StatsBooksFinishedThisYear": "Alguns llibres acabats aquest any…", + "StatsBooksListenedTo": "llibres escoltats", + "StatsCollectionGrewTo": "La teva col·lecció de llibres ha crescut fins a…", + "StatsSessions": "sessions", + "StatsSpentListening": "dedicat a escoltar", + "StatsTopAuthor": "AUTOR DESTACAT", + "StatsTopAuthors": "AUTORS DESTACATS", + "StatsTopGenre": "GÈNERE PRINCIPAL", + "StatsTopGenres": "GÈNERES PRINCIPALS", + "StatsTopMonth": "DESTACAT DEL MES", + "StatsTopNarrator": "NARRADOR DESTACAT", + "StatsTopNarrators": "NARRADORS DESTACATS", + "StatsTotalDuration": "Amb una durada total de…", + "StatsYearInReview": "RESUM DE L'ANY", + "ToastAccountUpdateSuccess": "Compte actualitzat", + "ToastAppriseUrlRequired": "Cal introduir una URL de Apprise", + "ToastAsinRequired": "ASIN requerit", + "ToastAuthorImageRemoveSuccess": "S'ha eliminat la imatge de l'autor", + "ToastAuthorNotFound": "No s'ha trobat l'autor \"{0}\"", + "ToastAuthorRemoveSuccess": "Autor eliminat", + "ToastAuthorSearchNotFound": "No s'ha trobat l'autor", + "ToastAuthorUpdateMerged": "Autor combinat", + "ToastAuthorUpdateSuccess": "Autor actualitzat", + "ToastAuthorUpdateSuccessNoImageFound": "Autor actualitzat (Imatge no trobada)", + "ToastBackupAppliedSuccess": "Còpia de seguretat aplicada", + "ToastBackupCreateFailed": "Error en crear la còpia de seguretat", + "ToastBackupCreateSuccess": "Còpia de seguretat creada", + "ToastBackupDeleteFailed": "Error en eliminar la còpia de seguretat", + "ToastBackupDeleteSuccess": "Còpia de seguretat eliminada", + "ToastBackupInvalidMaxKeep": "Nombre no vàlid de còpies de seguretat a conservar", + "ToastBackupInvalidMaxSize": "Mida màxima de còpia de seguretat no vàlida", + "ToastBackupRestoreFailed": "Error en restaurar la còpia de seguretat", + "ToastBackupUploadFailed": "Error en carregar la còpia de seguretat", + "ToastBackupUploadSuccess": "Còpia de seguretat carregada", + "ToastBatchDeleteFailed": "Error en l'eliminació per lots", + "ToastBatchDeleteSuccess": "Eliminació per lots correcte", + "ToastBatchQuickMatchFailed": "Error en la sincronització ràpida per lots!", + "ToastBatchQuickMatchStarted": "S'ha iniciat la sincronització ràpida per lots de {0} llibres!", + "ToastBatchUpdateFailed": "Error en l'actualització massiva", + "ToastBatchUpdateSuccess": "Actualització massiva completada", + "ToastBookmarkCreateFailed": "Error en crear marcador", + "ToastBookmarkCreateSuccess": "Marcador afegit", + "ToastBookmarkRemoveSuccess": "Marcador eliminat", + "ToastBookmarkUpdateSuccess": "Marcador actualitzat", + "ToastCachePurgeFailed": "Error en purgar la memòria cau", + "ToastCachePurgeSuccess": "Memòria cau purgada amb èxit", + "ToastChaptersHaveErrors": "Els capítols tenen errors", + "ToastChaptersMustHaveTitles": "Els capítols han de tenir un títol", + "ToastChaptersRemoved": "Capítols eliminats", + "ToastChaptersUpdated": "Capítols actualitzats", + "ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció", + "ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció", + "ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció", + "ToastCollectionRemoveSuccess": "Col·lecció eliminada", + "ToastCollectionUpdateSuccess": "Col·lecció actualitzada", + "ToastCoverUpdateFailed": "Error en actualitzar la portada", + "ToastDeleteFileFailed": "Error en eliminar l'arxiu", + "ToastDeleteFileSuccess": "Arxiu eliminat", + "ToastDeviceAddFailed": "Error en afegir el dispositiu", + "ToastDeviceNameAlreadyExists": "Ja existeix un dispositiu amb aquest nom", + "ToastDeviceTestEmailFailed": "Error en enviar el correu de prova", + "ToastDeviceTestEmailSuccess": "Correu de prova enviat", + "ToastEmailSettingsUpdateSuccess": "Configuració de correu electrònic actualitzada", + "ToastEncodeCancelFailed": "No s'ha pogut cancel·lar la codificació", + "ToastEncodeCancelSucces": "Codificació cancel·lada", + "ToastEpisodeDownloadQueueClearFailed": "No s'ha pogut buidar la cua de descàrregues", + "ToastEpisodeDownloadQueueClearSuccess": "Cua de descàrregues buidada", + "ToastEpisodeUpdateSuccess": "{0} episodi(s) actualitzat(s)", + "ToastErrorCannotShare": "No es pot compartir de manera nativa en aquest dispositiu", + "ToastFailedToLoadData": "Error en carregar les dades", + "ToastFailedToMatch": "Error en emparellar", + "ToastFailedToShare": "Error en compartir", + "ToastFailedToUpdate": "Error en actualitzar", + "ToastInvalidImageUrl": "URL de la imatge no vàlida", + "ToastInvalidMaxEpisodesToDownload": "Nombre màxim d'episodis per descarregar no vàlid", + "ToastInvalidUrl": "URL no vàlida", + "ToastItemCoverUpdateSuccess": "Portada de l'element actualitzada", + "ToastItemDeletedFailed": "Error en eliminar l'element", + "ToastItemDeletedSuccess": "Element eliminat", + "ToastItemDetailsUpdateSuccess": "Detalls de l'element actualitzats", + "ToastItemMarkedAsFinishedFailed": "Error en marcar com a acabat", + "ToastItemMarkedAsFinishedSuccess": "Element marcat com a acabat", + "ToastItemMarkedAsNotFinishedFailed": "Error en marcar com a no acabat", + "ToastItemMarkedAsNotFinishedSuccess": "Element marcat com a no acabat", + "ToastItemUpdateSuccess": "Element actualitzat", + "ToastLibraryCreateFailed": "Error en crear la biblioteca", + "ToastLibraryCreateSuccess": "Biblioteca \"{0}\" creada", + "ToastLibraryDeleteFailed": "Error en eliminar la biblioteca", + "ToastLibraryDeleteSuccess": "Biblioteca eliminada", + "ToastLibraryScanFailedToStart": "Error en iniciar l'escaneig", + "ToastLibraryScanStarted": "S'ha iniciat l'escaneig de la biblioteca", + "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualitzada", + "ToastMatchAllAuthorsFailed": "No coincideix amb tots els autors", + "ToastMetadataFilesRemovedError": "Error en eliminar metadades de {0} arxius", + "ToastMetadataFilesRemovedNoneFound": "No s'han trobat metadades en {0} arxius", + "ToastMetadataFilesRemovedNoneRemoved": "Cap metadada eliminada en {0} arxius", + "ToastMetadataFilesRemovedSuccess": "{0} metadades eliminades en {1} arxius", + "ToastMustHaveAtLeastOnePath": "Ha de tenir almenys una ruta", + "ToastNameEmailRequired": "El nom i el correu electrònic són obligatoris", + "ToastNameRequired": "Nom obligatori", + "ToastNewEpisodesFound": "{0} episodi(s) nou(s) trobat(s)", + "ToastNewUserCreatedFailed": "Error en crear el compte: \"{0}\"", + "ToastNewUserCreatedSuccess": "Nou compte creat", + "ToastNewUserLibraryError": "Ha de seleccionar almenys una biblioteca", + "ToastNewUserPasswordError": "Necessites una contrasenya, només el root pot estar sense contrasenya", + "ToastNewUserTagError": "Selecciona almenys una etiqueta", + "ToastNewUserUsernameError": "Introdueix un nom d'usuari", + "ToastNoNewEpisodesFound": "No s'han trobat nous episodis", + "ToastNoUpdatesNecessary": "No cal actualitzar", + "ToastNotificationCreateFailed": "Error en crear la notificació", + "ToastNotificationDeleteFailed": "Error en eliminar la notificació", + "ToastNotificationFailedMaximum": "El nombre màxim d'intents fallits ha de ser ≥ 0", + "ToastNotificationQueueMaximum": "La cua de notificació màxima ha de ser ≥ 0", + "ToastNotificationSettingsUpdateSuccess": "Configuració de notificació actualitzada", + "ToastNotificationTestTriggerFailed": "No s'ha pogut activar la notificació de prova", + "ToastNotificationTestTriggerSuccess": "Notificació de prova activada", + "ToastNotificationUpdateSuccess": "Notificació actualitzada", + "ToastPlaylistCreateFailed": "Error en crear la llista de reproducció", + "ToastPlaylistCreateSuccess": "Llista de reproducció creada", + "ToastPlaylistRemoveSuccess": "Llista de reproducció eliminada", + "ToastPlaylistUpdateSuccess": "Llista de reproducció actualitzada", + "ToastPodcastCreateFailed": "Error en crear el podcast", + "ToastPodcastCreateSuccess": "Podcast creat", + "ToastPodcastGetFeedFailed": "No s'ha pogut obtenir el podcast", + "ToastPodcastNoEpisodesInFeed": "No s'han trobat episodis en el feed RSS", + "ToastPodcastNoRssFeed": "El podcast no té un feed RSS", + "ToastProgressIsNotBeingSynced": "El progrés no s'està sincronitzant, reinicia la reproducció", + "ToastProviderCreatedFailed": "Error en afegir el proveïdor", + "ToastProviderCreatedSuccess": "Nou proveïdor afegit", + "ToastProviderNameAndUrlRequired": "Nom i URL obligatoris", + "ToastProviderRemoveSuccess": "Proveïdor eliminat", + "ToastRSSFeedCloseFailed": "Error en tancar el feed RSS", + "ToastRSSFeedCloseSuccess": "Feed RSS tancat", + "ToastRemoveFailed": "Error en eliminar", + "ToastRemoveItemFromCollectionFailed": "Error en eliminar l'element de la col·lecció", + "ToastRemoveItemFromCollectionSuccess": "Element eliminat de la col·lecció", + "ToastRemoveItemsWithIssuesFailed": "Error en eliminar elements incorrectes de la biblioteca", + "ToastRemoveItemsWithIssuesSuccess": "S'han eliminat els elements incorrectes de la biblioteca", + "ToastRenameFailed": "Error en canviar el nom", + "ToastRescanFailed": "Error en reescanejar per a {0}", + "ToastRescanRemoved": "Element reescanejat eliminat", + "ToastRescanUpToDate": "Reescaneig completat, l'element ja estava actualitzat", + "ToastRescanUpdated": "Reescaneig completat, l'element ha estat actualitzat", + "ToastScanFailed": "No s'ha pogut escanejar l'element de la biblioteca", + "ToastSelectAtLeastOneUser": "Selecciona almenys un usuari", + "ToastSendEbookToDeviceFailed": "Error en enviar l'ebook al dispositiu", + "ToastSendEbookToDeviceSuccess": "Ebook enviat al dispositiu \"{0}\"", + "ToastSeriesUpdateFailed": "Error en actualitzar la sèrie", + "ToastSeriesUpdateSuccess": "Sèrie actualitzada", + "ToastServerSettingsUpdateSuccess": "Configuració del servidor actualitzada", + "ToastSessionCloseFailed": "Error en tancar la sessió", + "ToastSessionDeleteFailed": "Error en eliminar la sessió", + "ToastSessionDeleteSuccess": "Sessió eliminada", + "ToastSleepTimerDone": "Temporitzador d'apagada activat... zZzzZz", + "ToastSlugMustChange": "L'slug conté caràcters no vàlids", + "ToastSlugRequired": "Slug obligatori", + "ToastSocketConnected": "Socket connectat", + "ToastSocketDisconnected": "Socket desconnectat", + "ToastSocketFailedToConnect": "Error en connectar al Socket", + "ToastSortingPrefixesEmptyError": "Cal tenir almenys 1 prefix per ordenar", + "ToastSortingPrefixesUpdateSuccess": "Prefixos d'ordenació actualitzats ({0} elements)", + "ToastTitleRequired": "Títol obligatori", + "ToastUnknownError": "Error desconegut", + "ToastUnlinkOpenIdFailed": "Error en desvincular l'usuari d'OpenID", + "ToastUnlinkOpenIdSuccess": "Usuari desvinculat d'OpenID", + "ToastUserDeleteFailed": "Error en eliminar l'usuari", + "ToastUserDeleteSuccess": "Usuari eliminat", + "ToastUserPasswordChangeSuccess": "Contrasenya canviada correctament", + "ToastUserPasswordMismatch": "Les contrasenyes no coincideixen", + "ToastUserPasswordMustChange": "La nova contrasenya no pot ser igual a l'anterior", + "ToastUserRootRequireName": "Cal introduir un nom d'usuari root" +} + + From 7486d6345dd03357a6e069bd789cdaad5da785c2 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 09:34:06 +0100 Subject: [PATCH 461/539] Resolved a server crash when a playback session lacked associated media metadata. --- server/utils/queries/userStats.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index 76b69ed784..fbba7129a0 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -127,20 +127,20 @@ module.exports = { bookListeningMap[ls.displayTitle] += listeningSessionListeningTime } - const authors = ls.mediaMetadata.authors || [] + const authors = ls.mediaMetadata?.authors || [] authors.forEach((au) => { if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 authorListeningMap[au.name] += listeningSessionListeningTime }) - const narrators = ls.mediaMetadata.narrators || [] + const narrators = ls.mediaMetadata?.narrators || [] narrators.forEach((narrator) => { if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 narratorListeningMap[narrator] += listeningSessionListeningTime }) // Filter out bad genres like "audiobook" and "audio book" - const genres = (ls.mediaMetadata.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) genres.forEach((genre) => { if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 genreListeningMap[genre] += listeningSessionListeningTime From 9b8e059efe68bb21500f2b84de36f54d5750ba97 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 7 Dec 2024 19:27:37 +0200 Subject: [PATCH 462/539] Remove serverAddress from Feeds and FeedEpisodes URLs --- .../modals/rssfeed/OpenCloseModal.vue | 9 +- .../modals/rssfeed/ViewFeedModal.vue | 7 +- client/pages/config/rss-feeds.vue | 2 +- server/Server.js | 4 + server/managers/RssFeedManager.js | 2 +- .../v2.17.5-remove-host-from-feed-urls.js | 74 +++++++ server/objects/Feed.js | 30 +-- server/objects/FeedEpisode.js | 16 +- server/objects/FeedMeta.js | 32 ++- ...v2.17.5-remove-host-from-feed-urls.test.js | 202 ++++++++++++++++++ 10 files changed, 331 insertions(+), 47 deletions(-) create mode 100644 server/migrations/v2.17.5-remove-host-from-feed-urls.js create mode 100644 test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js diff --git a/client/components/modals/rssfeed/OpenCloseModal.vue b/client/components/modals/rssfeed/OpenCloseModal.vue index 53542cf553..4eff94013a 100644 --- a/client/components/modals/rssfeed/OpenCloseModal.vue +++ b/client/components/modals/rssfeed/OpenCloseModal.vue @@ -10,9 +10,9 @@

{{ $strings.HeaderRSSFeedIsOpen }}

- + - content_copy + content_copy
@@ -111,8 +111,11 @@ export default { userIsAdminOrUp() { return this.$store.getters['user/getIsAdminOrUp'] }, + feedUrl() { + return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : '' + }, demoFeedUrl() { - return `${window.origin}/feed/${this.newFeedSlug}` + return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}` }, isHttp() { return window.origin.startsWith('http://') diff --git a/client/components/modals/rssfeed/ViewFeedModal.vue b/client/components/modals/rssfeed/ViewFeedModal.vue index cd06350bb6..704125179b 100644 --- a/client/components/modals/rssfeed/ViewFeedModal.vue +++ b/client/components/modals/rssfeed/ViewFeedModal.vue @@ -5,8 +5,8 @@

{{ $strings.HeaderRSSFeedGeneral }}

- - content_copy + + content_copy
@@ -70,6 +70,9 @@ export default { }, _feed() { return this.feed || {} + }, + feedUrl() { + return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : '' } }, methods: { diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 68117a859c..039e9a0dfb 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -126,7 +126,7 @@ export default { }, coverUrl(feed) { if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png` - return `${feed.feedUrl}/cover` + return `${this.$config.routerBasePath}${feed.feedUrl}/cover` }, async loadFeeds() { const data = await this.$axios.$get(`/api/feeds`).catch((err) => { diff --git a/server/Server.js b/server/Server.js index cd96733e98..dfcb474a49 100644 --- a/server/Server.js +++ b/server/Server.js @@ -253,6 +253,10 @@ class Server { // if RouterBasePath is set, modify all requests to include the base path if (global.RouterBasePath) { app.use((req, res, next) => { + const host = req.get('host') + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const prefix = req.url.startsWith(global.RouterBasePath) ? global.RouterBasePath : '' + req.originalHostPrefix = `${protocol}://${host}${prefix}` if (!req.url.startsWith(global.RouterBasePath)) { req.url = `${global.RouterBasePath}${req.url}` } diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 7716440dfa..8984a39b57 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -162,7 +162,7 @@ class RssFeedManager { } } - const xml = feed.buildXml() + const xml = feed.buildXml(req.originalHostPrefix) res.set('Content-Type', 'text/xml') res.send(xml) } diff --git a/server/migrations/v2.17.5-remove-host-from-feed-urls.js b/server/migrations/v2.17.5-remove-host-from-feed-urls.js new file mode 100644 index 0000000000..e08877f23e --- /dev/null +++ b/server/migrations/v2.17.5-remove-host-from-feed-urls.js @@ -0,0 +1,74 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.17.5' +const migrationName = `${migrationVersion}-remove-host-from-feed-urls` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`) + await queryInterface.sequelize.query(` + UPDATE Feeds + SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''), + imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''), + siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), ''); + `) + logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`) + + logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`) + await queryInterface.sequelize.query(` + UPDATE FeedEpisodes + SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''), + enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''); + `) + logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`) + await queryInterface.sequelize.query(` + UPDATE Feeds + SET feedUrl = COALESCE(serverAddress, '') || feedUrl, + imageUrl = COALESCE(serverAddress, '') || imageUrl, + siteUrl = COALESCE(serverAddress, '') || siteUrl; + `) + logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`) + + logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`) + await queryInterface.sequelize.query(` + UPDATE FeedEpisodes + SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), + enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId); + `) + logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 74a220e35c..da76067d47 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -109,7 +109,7 @@ class Feed { const mediaMetadata = media.metadata const isPodcast = libraryItem.mediaType === 'podcast' - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName this.id = uuidv4() @@ -128,9 +128,9 @@ class Feed { this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/item/${libraryItem.id}` + this.meta.link = `/item/${libraryItem.id}` this.meta.explicit = !!mediaMetadata.explicit this.meta.type = mediaMetadata.type this.meta.language = mediaMetadata.language @@ -176,7 +176,7 @@ class Feed { this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!mediaMetadata.explicit this.meta.type = mediaMetadata.type this.meta.language = mediaMetadata.language @@ -206,7 +206,7 @@ class Feed { } setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath) @@ -227,9 +227,9 @@ class Feed { this.meta.title = collectionExpanded.name this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}` + this.meta.link = `/collection/${collectionExpanded.id}` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.meta.preventIndexing = preventIndexing this.meta.ownerName = ownerName @@ -272,7 +272,7 @@ class Feed { this.meta.title = collectionExpanded.name this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] @@ -301,7 +301,7 @@ class Feed { } setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) // Sort series items by series sequence @@ -326,9 +326,9 @@ class Feed { this.meta.title = seriesExpanded.name this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}` + this.meta.link = `/library/${libraryId}/series/${seriesExpanded.id}` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.meta.preventIndexing = preventIndexing this.meta.ownerName = ownerName @@ -374,7 +374,7 @@ class Feed { this.meta.title = seriesExpanded.name this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] @@ -402,12 +402,12 @@ class Feed { this.xml = null } - buildXml() { + buildXml(originalHostPrefix) { if (this.xml) return this.xml - var rssfeed = new RSS(this.meta.getRSSData()) + var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix)) this.episodes.forEach((ep) => { - rssfeed.item(ep.getRSSData()) + rssfeed.item(ep.getRSSData(originalHostPrefix)) }) this.xml = rssfeed.xml() return this.xml diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index 6d9f36a087..13d590ff7c 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -79,7 +79,7 @@ class FeedEpisode { this.title = episode.title this.description = episode.description || '' this.enclosure = { - url: `${serverAddress}${contentUrl}`, + url: `${contentUrl}`, type: episode.audioTrack.mimeType, size: episode.size } @@ -136,7 +136,7 @@ class FeedEpisode { this.title = title this.description = mediaMetadata.description || '' this.enclosure = { - url: `${serverAddress}${contentUrl}`, + url: `${contentUrl}`, type: audioTrack.mimeType, size: audioTrack.metadata.size } @@ -151,15 +151,19 @@ class FeedEpisode { this.fullPath = audioTrack.metadata.path } - getRSSData() { + getRSSData(hostPrefix) { return { title: this.title, description: this.description || '', - url: this.link, - guid: this.enclosure.url, + url: `${hostPrefix}${this.link}`, + guid: `${hostPrefix}${this.enclosure.url}`, author: this.author, date: this.pubDate, - enclosure: this.enclosure, + enclosure: { + url: `${hostPrefix}${this.enclosure.url}`, + type: this.enclosure.type, + size: this.enclosure.size + }, custom_elements: [ { 'itunes:author': this.author }, { 'itunes:duration': secondsToTimestamp(this.duration) }, diff --git a/server/objects/FeedMeta.js b/server/objects/FeedMeta.js index 307e12bc1a..e439fe8f78 100644 --- a/server/objects/FeedMeta.js +++ b/server/objects/FeedMeta.js @@ -60,42 +60,36 @@ class FeedMeta { } } - getRSSData() { - const blockTags = [ - { 'itunes:block': 'yes' }, - { 'googleplay:block': 'yes' } - ] + getRSSData(hostPrefix) { + const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }] return { title: this.title, description: this.description || '', generator: 'Audiobookshelf', - feed_url: this.feedUrl, - site_url: this.link, - image_url: this.imageUrl, + feed_url: `${hostPrefix}${this.feedUrl}`, + site_url: `${hostPrefix}${this.link}`, + image_url: `${hostPrefix}${this.imageUrl}`, custom_namespaces: { - 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd', - 'psc': 'http://podlove.org/simple-chapters', - 'podcast': 'https://podcastindex.org/namespace/1.0', - 'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0' + itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', + psc: 'http://podlove.org/simple-chapters', + podcast: 'https://podcastindex.org/namespace/1.0', + googleplay: 'http://www.google.com/schemas/play-podcasts/1.0' }, custom_elements: [ - { 'language': this.language || 'en' }, - { 'author': this.author || 'advplyr' }, + { language: this.language || 'en' }, + { author: this.author || 'advplyr' }, { 'itunes:author': this.author || 'advplyr' }, { 'itunes:summary': this.description || '' }, { 'itunes:type': this.type }, { 'itunes:image': { _attr: { - href: this.imageUrl + href: `${hostPrefix}${this.imageUrl}` } } }, { - 'itunes:owner': [ - { 'itunes:name': this.ownerName || this.author || '' }, - { 'itunes:email': this.ownerEmail || '' } - ] + 'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }] }, { 'itunes:explicit': !!this.explicit }, ...(this.preventIndexing ? blockTags : []) diff --git a/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js b/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js new file mode 100644 index 0000000000..786ed6ae6a --- /dev/null +++ b/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js @@ -0,0 +1,202 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.17.5-remove-host-from-feed-urls') +const { Sequelize, DataTypes } = require('sequelize') +const Logger = require('../../../server/Logger') + +const defineModels = (sequelize) => { + const Feeds = sequelize.define('Feeds', { + id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, + feedUrl: { type: DataTypes.STRING }, + imageUrl: { type: DataTypes.STRING }, + siteUrl: { type: DataTypes.STRING }, + serverAddress: { type: DataTypes.STRING } + }) + + const FeedEpisodes = sequelize.define('FeedEpisodes', { + id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, + feedId: { type: DataTypes.UUID }, + siteUrl: { type: DataTypes.STRING }, + enclosureUrl: { type: DataTypes.STRING } + }) + + return { Feeds, FeedEpisodes } +} + +describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => { + let queryInterface, logger, context + let sequelize + let Feeds, FeedEpisodes + const feed1Id = '00000000-0000-4000-a000-000000000001' + const feed2Id = '00000000-0000-4000-a000-000000000002' + const feedEpisode1Id = '00000000-4000-a000-0000-000000000011' + const feedEpisode2Id = '00000000-4000-a000-0000-000000000012' + const feedEpisode3Id = '00000000-4000-a000-0000-000000000021' + + before(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + ;({ Feeds, FeedEpisodes } = defineModels(sequelize)) + await sequelize.sync() + }) + + after(async () => { + await sequelize.close() + }) + + beforeEach(async () => { + // Reset tables before each test + await Feeds.destroy({ where: {}, truncate: true }) + await FeedEpisodes.destroy({ where: {}, truncate: true }) + + logger = { + info: sinon.stub(), + error: sinon.stub() + } + context = { queryInterface, logger } + }) + + describe('up', () => { + it('should remove serverAddress from URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([ + { id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' }, + { id: feed2Id, feedUrl: 'http://server2.com/feed2', imageUrl: 'http://server2.com/img2', siteUrl: 'http://server2.com/site2', serverAddress: 'http://server2.com' } + ]) + + await FeedEpisodes.bulkCreate([ + { id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' }, + { id: feedEpisode2Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode12', enclosureUrl: 'http://server1.com/enclosure12' }, + { id: feedEpisode3Id, feedId: feed2Id, siteUrl: 'http://server2.com/episode21', enclosureUrl: 'http://server2.com/enclosure21' } + ]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(logger.info.calledWith('[2.17.5 migration] UPGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from Feeds table URLs')).to.be.true + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.equal('/img1') + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feeds[1].feedUrl).to.equal('/feed2') + expect(feeds[1].imageUrl).to.equal('/img2') + expect(feeds[1].siteUrl).to.equal('/site2') + + expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from Feeds table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from FeedEpisodes table URLs')).to.be.true + + expect(feedEpisodes[0].siteUrl).to.equal('/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + expect(feedEpisodes[1].siteUrl).to.equal('/episode12') + expect(feedEpisodes[1].enclosureUrl).to.equal('/enclosure12') + expect(feedEpisodes[2].siteUrl).to.equal('/episode21') + expect(feedEpisodes[2].enclosureUrl).to.equal('/enclosure21') + + expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from FeedEpisodes table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] UPGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true + }) + + it('should handle null URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: null, siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' }]) + + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: 'http://server1.com/enclosure11' }]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.be.null + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feedEpisodes[0].siteUrl).to.be.null + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + }) + + it('should handle null serverAddress in Feeds table', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: null }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' }]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.equal('http://server1.com/img1') + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + }) + }) + + describe('down', () => { + it('should add serverAddress back to URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([ + { id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: 'http://server1.com' }, + { id: feed2Id, feedUrl: '/feed2', imageUrl: '/img2', siteUrl: '/site2', serverAddress: 'http://server2.com' } + ]) + + await FeedEpisodes.bulkCreate([ + { id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' }, + { id: feedEpisode2Id, feedId: feed1Id, siteUrl: '/episode12', enclosureUrl: '/enclosure12' }, + { id: feedEpisode3Id, feedId: feed2Id, siteUrl: '/episode21', enclosureUrl: '/enclosure21' } + ]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to Feeds table URLs')).to.be.true + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.equal('http://server1.com/img1') + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feeds[1].feedUrl).to.equal('http://server2.com/feed2') + expect(feeds[1].imageUrl).to.equal('http://server2.com/img2') + expect(feeds[1].siteUrl).to.equal('http://server2.com/site2') + + expect(logger.info.calledWith('[2.17.5 migration] Added serverAddress back to Feeds table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to FeedEpisodes table URLs')).to.be.true + + expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + expect(feedEpisodes[1].siteUrl).to.equal('http://server1.com/episode12') + expect(feedEpisodes[1].enclosureUrl).to.equal('http://server1.com/enclosure12') + expect(feedEpisodes[2].siteUrl).to.equal('http://server2.com/episode21') + expect(feedEpisodes[2].enclosureUrl).to.equal('http://server2.com/enclosure21') + + expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true + }) + + it('should handle null URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: null, siteUrl: '/site1', serverAddress: 'http://server1.com' }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: '/enclosure11' }]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.be.null + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feedEpisodes[0].siteUrl).to.be.null + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + }) + + it('should handle null serverAddress in Feeds table', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: null }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' }]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.equal('/img1') + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feedEpisodes[0].siteUrl).to.equal('/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + }) + }) +}) From 6fa11934be0e7c5b28c423f495377980a7e9fb63 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 7 Dec 2024 15:15:47 -0600 Subject: [PATCH 463/539] Add:Catalan language option --- client/plugins/i18n.js | 1 + client/strings/ca.json | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 0ec5cccee4..12d2b44bc4 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -7,6 +7,7 @@ const defaultCode = 'en-us' const languageCodeMap = { bg: { label: 'Български', dateFnsLocale: 'bg' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, + ca: { label: 'Català', dateFnsLocale: 'ca' }, cs: { label: 'Čeština', dateFnsLocale: 'cs' }, da: { label: 'Dansk', dateFnsLocale: 'da' }, de: { label: 'Deutsch', dateFnsLocale: 'de' }, diff --git a/client/strings/ca.json b/client/strings/ca.json index 8dde850b84..f7e85ae251 100644 --- a/client/strings/ca.json +++ b/client/strings/ca.json @@ -1025,5 +1025,3 @@ "ToastUserPasswordMustChange": "La nova contrasenya no pot ser igual a l'anterior", "ToastUserRootRequireName": "Cal introduir un nom d'usuari root" } - - From 61729881cb0bfca2f7a22da06597713acbc043b2 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 7 Dec 2024 16:52:31 -0700 Subject: [PATCH 464/539] Change: no compression when downloading library item as zip file --- server/utils/zipHelpers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/utils/zipHelpers.js b/server/utils/zipHelpers.js index c161727205..44b65296e5 100644 --- a/server/utils/zipHelpers.js +++ b/server/utils/zipHelpers.js @@ -7,7 +7,7 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { res.attachment(filename) const archive = archiver('zip', { - zlib: { level: 9 } // Sets the compression level. + zlib: { level: 0 } // Sets the compression level. }) // listen for all archive data to be written @@ -49,4 +49,4 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { archive.finalize() }) -} \ No newline at end of file +} From a8ab8badd5c42e1794715a370b6a8ae60c6b8652 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 8 Dec 2024 09:23:39 +0200 Subject: [PATCH 465/539] always set req.originalHostPrefix --- server/Server.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/server/Server.js b/server/Server.js index dfcb474a49..7959827508 100644 --- a/server/Server.js +++ b/server/Server.js @@ -251,18 +251,17 @@ class Server { const router = express.Router() // if RouterBasePath is set, modify all requests to include the base path - if (global.RouterBasePath) { - app.use((req, res, next) => { - const host = req.get('host') - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const prefix = req.url.startsWith(global.RouterBasePath) ? global.RouterBasePath : '' - req.originalHostPrefix = `${protocol}://${host}${prefix}` - if (!req.url.startsWith(global.RouterBasePath)) { - req.url = `${global.RouterBasePath}${req.url}` - } - next() - }) - } + app.use((req, res, next) => { + const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath) + const host = req.get('host') + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : '' + req.originalHostPrefix = `${protocol}://${host}${prefix}` + if (!urlStartsWithRouterBasePath) { + req.url = `${global.RouterBasePath}${req.url}` + } + next() + }) app.use(global.RouterBasePath, router) app.disable('x-powered-by') From b38ce4173144a9d33330ac3b59fbf7faf8320292 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 8 Dec 2024 09:48:58 +0200 Subject: [PATCH 466/539] Remove xml cache from Feed object --- server/objects/Feed.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/server/objects/Feed.js b/server/objects/Feed.js index da76067d47..ac50b899fe 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -29,9 +29,6 @@ class Feed { this.createdAt = null this.updatedAt = null - // Cached xml - this.xml = null - if (feed) { this.construct(feed) } @@ -202,7 +199,6 @@ class Feed { } this.updatedAt = Date.now() - this.xml = null } setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { @@ -297,7 +293,6 @@ class Feed { }) this.updatedAt = Date.now() - this.xml = null } setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { @@ -399,18 +394,14 @@ class Feed { }) this.updatedAt = Date.now() - this.xml = null } buildXml(originalHostPrefix) { - if (this.xml) return this.xml - var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix)) this.episodes.forEach((ep) => { rssfeed.item(ep.getRSSData(originalHostPrefix)) }) - this.xml = rssfeed.xml() - return this.xml + return rssfeed.xml() } getAuthorsStringFromLibraryItems(libraryItems) { From 5646466aa371cc03f12496cd0a1d28de34839734 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:05:33 -0600 Subject: [PATCH 467/539] Update JSDocs for feeds endpoints --- server/managers/RssFeedManager.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 8984a39b57..583f0bb678 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -1,3 +1,4 @@ +const { Request, Response } = require('express') const Path = require('path') const Logger = require('../Logger') @@ -77,6 +78,12 @@ class RssFeedManager { return Database.feedModel.findByPkOld(id) } + /** + * GET: /feed/:slug + * + * @param {Request} req + * @param {Response} res + */ async getFeed(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { @@ -167,6 +174,12 @@ class RssFeedManager { res.send(xml) } + /** + * GET: /feed/:slug/item/:episodeId/* + * + * @param {Request} req + * @param {Response} res + */ async getFeedItem(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { @@ -183,6 +196,12 @@ class RssFeedManager { res.sendFile(episodePath) } + /** + * GET: /feed/:slug/cover* + * + * @param {Request} req + * @param {Response} res + */ async getFeedCover(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { From f7b7b85673fb8a5ac1a9b9c09e1bb686aa7d2f90 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:19:23 -0600 Subject: [PATCH 468/539] Add v2.17.5 migration to changelog --- server/migrations/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index f46cd4ae7b..f49924327a 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -10,3 +10,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | | v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | | v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | +| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables | From 57906540fef30b2b8801e4abbf38ca12d7307f9f Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:57:45 -0600 Subject: [PATCH 469/539] Add:Server setting to allow iframe & update UI to differentiate web client settings #3684 --- client/pages/config/index.vue | 47 ++++++++++++++--------- client/store/index.js | 11 +++--- client/strings/en-us.json | 2 + server/Server.js | 3 +- server/controllers/MiscController.js | 5 ++- server/objects/settings/ServerSettings.js | 8 ++++ 6 files changed, 49 insertions(+), 27 deletions(-) diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 1f0d61ebc1..bbb75b9342 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -42,11 +42,6 @@
-
- -

{{ $strings.LabelSettingsChromecastSupport }}

-
-

{{ $strings.HeaderSettingsScanner }}

@@ -94,6 +89,20 @@

+ +
+

{{ $strings.HeaderSettingsWebClient }}

+
+ +
+ +

{{ $strings.LabelSettingsChromecastSupport }}

+
+ +
+ +

{{ $strings.LabelSettingsAllowIframe }}

+
@@ -324,21 +333,21 @@ export default { }, updateServerSettings(payload) { this.updatingServerSettings = true - this.$store - .dispatch('updateServerSettings', payload) - .then(() => { - this.updatingServerSettings = false + this.$store.dispatch('updateServerSettings', payload).then((response) => { + this.updatingServerSettings = false - if (payload.language) { - // Updating language after save allows for re-rendering - this.$setLanguageCode(payload.language) - } - }) - .catch((error) => { - console.error('Failed to update server settings', error) - this.updatingServerSettings = false - this.$toast.error(this.$strings.ToastFailedToUpdate) - }) + if (response.error) { + console.error('Failed to update server settins', response.error) + this.$toast.error(response.error) + this.initServerSettings() + return + } + + if (payload.language) { + // Updating language after save allows for re-rendering + this.$setLanguageCode(payload.language) + } + }) }, initServerSettings() { this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} diff --git a/client/store/index.js b/client/store/index.js index acd03eb468..2f2201b66c 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -72,16 +72,17 @@ export const actions = { return this.$axios .$patch('/api/settings', updatePayload) .then((result) => { - if (result.success) { + if (result.serverSettings) { commit('setServerSettings', result.serverSettings) - return true - } else { - return false } + return result }) .catch((error) => { console.error('Failed to update server settings', error) - return false + const errorMsg = error.response?.data || 'Unknown error' + return { + error: errorMsg + } }) }, checkForUpdate({ commit }) { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 75069cd337..805e8f48be 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -190,6 +190,7 @@ "HeaderSettingsExperimental": "Experimental Features", "HeaderSettingsGeneral": "General", "HeaderSettingsScanner": "Scanner", + "HeaderSettingsWebClient": "Web Client", "HeaderSleepTimer": "Sleep Timer", "HeaderStatsLargestItems": "Largest Items", "HeaderStatsLongestItems": "Longest Items (hrs)", @@ -542,6 +543,7 @@ "LabelServerYearReview": "Server Year in Review ({0})", "LabelSetEbookAsPrimary": "Set as primary", "LabelSetEbookAsSupplementary": "Set as supplementary", + "LabelSettingsAllowIframe": "Allow embedding in an iframe", "LabelSettingsAudiobooksOnly": "Audiobooks only", "LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", diff --git a/server/Server.js b/server/Server.js index 7959827508..2f1220d87c 100644 --- a/server/Server.js +++ b/server/Server.js @@ -53,7 +53,6 @@ class Server { global.RouterBasePath = ROUTER_BASE_PATH global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' - global.AllowIframe = process.env.ALLOW_IFRAME === '1' global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' if (!fs.pathExistsSync(global.ConfigPath)) { @@ -195,7 +194,7 @@ class Server { const app = express() app.use((req, res, next) => { - if (!global.AllowIframe) { + if (!global.ServerSettings.allowIframe) { // Prevent clickjacking by disallowing iframes res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") } diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 2a87f2fef6..b35619b70b 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -126,6 +126,10 @@ class MiscController { if (!isObject(settingsUpdate)) { return res.status(400).send('Invalid settings update object') } + if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') { + Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment') + return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment') + } const madeUpdates = Database.serverSettings.update(settingsUpdate) if (madeUpdates) { @@ -137,7 +141,6 @@ class MiscController { } } return res.json({ - success: true, serverSettings: Database.serverSettings.toJSONForBrowser() }) } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index ff28027f5b..29913e4496 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -24,6 +24,7 @@ class ServerSettings { // Security/Rate limits this.rateLimitLoginRequests = 10 this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes + this.allowIframe = false // Backups this.backupPath = Path.join(global.MetadataPath, 'backups') @@ -99,6 +100,7 @@ class ServerSettings { this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10 this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes + this.allowIframe = !!settings.allowIframe this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups') this.backupSchedule = settings.backupSchedule || false @@ -190,6 +192,11 @@ class ServerSettings { Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`) this.backupPath = process.env.BACKUP_PATH } + + if (process.env.ALLOW_IFRAME === '1' && !this.allowIframe) { + Logger.info(`[ServerSettings] Using allowIframe from environment variable`) + this.allowIframe = true + } } toJSON() { @@ -207,6 +214,7 @@ class ServerSettings { metadataFileFormat: this.metadataFileFormat, rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginWindow: this.rateLimitLoginWindow, + allowIframe: this.allowIframe, backupPath: this.backupPath, backupSchedule: this.backupSchedule, backupsToKeep: this.backupsToKeep, From 5f72e30e63884c731e520849be60f224d30a2278 Mon Sep 17 00:00:00 2001 From: Clara Papke Date: Fri, 6 Dec 2024 16:56:14 +0000 Subject: [PATCH 470/539] Translated using Weblate (German) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index 865065aa7f..d3a10eadcc 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Zeige player Einstellungen", "LabelViewQueue": "Player-Warteschlange anzeigen", "LabelVolume": "Lautstärke", + "LabelWebRedirectURLsDescription": "Autorisieren Sie diese URLs bei ihrem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:", + "LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs", "LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelXBooks": "{0} Bücher", "LabelXItems": "{0} Medien", From e6d754113e95f780a3b18dd5be555da164048c76 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Fri, 6 Dec 2024 10:34:22 +0000 Subject: [PATCH 471/539] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 448bbf4c86..f2342636b6 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Переглянути налаштування програвача", "LabelViewQueue": "Переглянути чергу відтворення", "LabelVolume": "Гучність", + "LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:", + "LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL", "LabelWeekdaysToRun": "Виконувати у дні", "LabelXBooks": "{0} книг", "LabelXItems": "{0} елементів", From 8aaf62f2433aca7689e675a9c63b556ee19728e5 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 6 Dec 2024 10:03:47 +0000 Subject: [PATCH 472/539] Translated using Weblate (Slovenian) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index e80ac8b271..58500f9fb7 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Ogled nastavitev predvajalnika", "LabelViewQueue": "Ogled čakalno vrsto predvajalnika", "LabelVolume": "Glasnost", + "LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:", + "LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve", "LabelWeekdaysToRun": "Delovni dnevi predvajanja", "LabelXBooks": "{0} knjig", "LabelXItems": "{0} elementov", From 190a1000d9b5909b5bcd953f32f39fa8f261ecb9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 09:03:05 -0600 Subject: [PATCH 473/539] Version bump v2.17.5 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index e4e3236cec..807976bdef 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index ea19190175..6f9d9d4465 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 10db84ea92..efa917dcd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index c122240a7e..2e9c97090d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 4610e58337eb3953a7c6efd533c6d0ae36722d45 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 9 Dec 2024 17:24:21 -0600 Subject: [PATCH 474/539] Update:Home shelf labels use h2 tag, play & edit buttons overlaying item page updated to button tag with aria-label for accessibility #3699 --- client/components/app/BookShelfCategorized.vue | 2 +- client/pages/item/_id/index.vue | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index a977dd213d..94b2e4bae5 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -17,7 +17,7 @@
diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1baf521c70..2e7e601c7a 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -12,12 +12,12 @@
-
+
+
- edit +
@@ -87,7 +87,7 @@ - error + error {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} From c5c3aab130ccf67316d61300b131752a41fb187f Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 10 Dec 2024 17:19:47 -0600 Subject: [PATCH 475/539] Update:Accessibility for buttons on item page, context menu dropdown, library filter/sort #3699 --- .../controls/LibraryFilterSelect.vue | 30 ++++++++++--------- .../components/controls/LibrarySortSelect.vue | 10 +++---- client/components/ui/ContextMenuDropdown.vue | 16 +++++----- client/components/ui/IconBtn.vue | 5 ++-- client/components/ui/LibrariesDropdown.vue | 6 ++-- client/components/ui/ReadIconBtn.vue | 2 +- client/pages/item/_id/index.vue | 8 ++--- client/strings/en-us.json | 2 ++ 8 files changed, 42 insertions(+), 37 deletions(-) diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index 2d9ced5a44..c600d80fb2 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -1,28 +1,30 @@