mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-24 01:13:00 -04:00
Merge branch 'advplyr:master' into tailwind-4-migration
This commit is contained in:
commit
ca7852171b
6
.github/workflows/close_blank_issues.yaml
vendored
6
.github/workflows/close_blank_issues.yaml
vendored
@ -14,11 +14,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check issue headings
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
|
||||
// Match Markdown headings (e.g., # Heading, ## Heading)
|
||||
const headingRegex = /^(#{1,6})\s.+/gm;
|
||||
const headings = [...issueBody.matchAll(headingRegex)];
|
||||
@ -39,4 +39,4 @@ jobs:
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
21
.github/workflows/docker-build.yml
vendored
21
.github/workflows/docker-build.yml
vendored
@ -1,5 +1,4 @@
|
||||
---
|
||||
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
@ -11,7 +10,7 @@ on:
|
||||
required: true
|
||||
default: 'latest'
|
||||
push:
|
||||
branches: [main,master]
|
||||
branches: [main, master]
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
# Only build when files in these directories have been changed
|
||||
@ -23,16 +22,16 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||
if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }}
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||
tags: |
|
||||
@ -40,13 +39,13 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
@ -54,20 +53,20 @@ jobs:
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to Dockerhub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to ghcr
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
3
.github/workflows/i18n-integration.yml
vendored
3
.github/workflows/i18n-integration.yml
vendored
@ -20,7 +20,8 @@ jobs:
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
# stored.
|
||||
|
9
.github/workflows/integration-test.yml
vendored
9
.github/workflows/integration-test.yml
vendored
@ -18,14 +18,15 @@ jobs:
|
||||
name: build and test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: setup nade
|
||||
uses: actions/setup-node@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: install pkg (using yao-pkg fork for targetting node20)
|
||||
- name: install pkg (using yao-pkg fork for targeting node20)
|
||||
run: npm install -g @yao-pkg/pkg
|
||||
|
||||
- name: get client dependencies
|
||||
|
7
.github/workflows/lint-openapi.yml
vendored
7
.github/workflows/lint-openapi.yml
vendored
@ -18,15 +18,22 @@ jobs:
|
||||
# Check out the repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up node to run the javascript
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
# Install Redocly CLI
|
||||
- name: Install Redocly CLI
|
||||
run: npm install -g @redocly/cli@latest
|
||||
|
||||
# Perform linting for exploded spec
|
||||
- name: Run linting for exploded spec
|
||||
run: redocly lint docs/root.yaml --format=github-actions
|
||||
|
||||
# Perform linting for bundled spec
|
||||
- name: Run linting for bundled spec
|
||||
run: redocly lint docs/openapi.json --format=github-actions
|
||||
|
1
.github/workflows/unit-tests.yml
vendored
1
.github/workflows/unit-tests.yml
vendored
@ -29,6 +29,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
@ -180,6 +180,15 @@ export default {
|
||||
action: 'rescan'
|
||||
})
|
||||
|
||||
// The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440
|
||||
// + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains
|
||||
if (this.selectedMediaItems.length <= 40) {
|
||||
options.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
},
|
||||
@ -215,6 +224,8 @@ export default {
|
||||
this.batchAutoMatchClick()
|
||||
} else if (action === 'rescan') {
|
||||
this.batchRescan()
|
||||
} else if (action === 'download') {
|
||||
this.batchDownload()
|
||||
}
|
||||
},
|
||||
async batchRescan() {
|
||||
@ -241,6 +252,11 @@ export default {
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
async batchDownload() {
|
||||
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
|
||||
console.log('Downloading library items', libraryItemIds)
|
||||
this.$downloadFile(`/api/libraries/${this.$store.state.libraries.currentLibraryId}/download?token=${this.$store.getters['user/getToken']}&ids=${libraryItemIds.join(',')}`)
|
||||
},
|
||||
async playSelectedItems() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
|
@ -74,21 +74,32 @@ export default {
|
||||
this.newPlaylistDescription = this.playlist.description || ''
|
||||
},
|
||||
removeClick() {
|
||||
if (confirm(this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]))) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/playlists/${this.playlist.id}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove playlist', error)
|
||||
this.processing = false
|
||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
||||
})
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.removePlaylist()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
removePlaylist() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/playlists/${this.playlist.id}`)
|
||||
.then(() => {
|
||||
this.show = false
|
||||
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove playlist', error)
|
||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (this.newPlaylistName === this.playlistName && this.newPlaylistDescription === this.playlist.description) {
|
||||
|
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.5",
|
||||
"version": "2.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.5",
|
||||
"version": "2.20.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.5",
|
||||
"version": "2.20.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
@ -109,21 +109,31 @@ export default {
|
||||
this.$store.commit('globals/setEditPlaylist', this.playlist)
|
||||
},
|
||||
removeClick() {
|
||||
if (confirm(`Are you sure you want to remove playlist "${this.playlistName}"?`)) {
|
||||
this.processingRemove = true
|
||||
var playlistName = this.playlistName
|
||||
this.$axios
|
||||
.$delete(`/api/playlists/${this.playlist.id}`)
|
||||
.then(() => {
|
||||
this.processingRemove = false
|
||||
this.$toast.success(`Playlist "${playlistName}" Removed`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove playlist', error)
|
||||
this.processingRemove = false
|
||||
this.$toast.error(`Failed to remove playlist`)
|
||||
})
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.removePlaylist()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
removePlaylist() {
|
||||
this.processingRemove = true
|
||||
this.$axios
|
||||
.$delete(`/api/playlists/${this.playlist.id}`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove playlist', error)
|
||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processingRemove = false
|
||||
})
|
||||
},
|
||||
clickPlay() {
|
||||
const queueItems = []
|
||||
|
@ -706,6 +706,7 @@
|
||||
"MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.",
|
||||
"MessageBackupsLocationPathEmpty": "Der Backup-Pfad darf nicht leer sein",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Fülle die aktivierten Felder mit Daten aus allen Elementen. Felder mit mehreren Werten werden zusammengeführt",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Aktivierte Felder für Kartendetails mit Daten aus diesem Element füllen",
|
||||
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
|
||||
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
|
||||
"MessageBookshelfNoCollectionsHelp": "Sammlungen sind öffentlich. Alle Benutzer mit Zugriff auf die Bibliothek können sie sehen.",
|
||||
|
@ -70,7 +70,7 @@
|
||||
"ButtonQueueRemoveItem": "Poista jonosta",
|
||||
"ButtonQuickEmbed": "Pikaupota",
|
||||
"ButtonQuickEmbedMetadata": "Upota kuvailutiedot nopeasti",
|
||||
"ButtonQuickMatch": "Pikatäsmää",
|
||||
"ButtonQuickMatch": "Pikatäsmäys",
|
||||
"ButtonReScan": "Uudelleenskannaa",
|
||||
"ButtonRead": "Lue",
|
||||
"ButtonReadLess": "Lue vähemmän",
|
||||
@ -135,7 +135,7 @@
|
||||
"HeaderCustomMetadataProviders": "Mukautetut metadatan tarjoajat",
|
||||
"HeaderDetails": "Yksityiskohdat",
|
||||
"HeaderDownloadQueue": "Latausjono",
|
||||
"HeaderEbookFiles": "E-kirjatiedostot",
|
||||
"HeaderEbookFiles": "S-kirjatiedostot",
|
||||
"HeaderEmail": "Sähköposti",
|
||||
"HeaderEmailSettings": "Sähköpostiasetukset",
|
||||
"HeaderEpisodes": "Jaksot",
|
||||
@ -268,7 +268,7 @@
|
||||
"LabelChapterCount": "{0} lukua",
|
||||
"LabelChapterTitle": "Luvun nimi",
|
||||
"LabelChapters": "Luvut",
|
||||
"LabelChaptersFound": "lukua löydetty",
|
||||
"LabelChaptersFound": "lukuja löydetty",
|
||||
"LabelClickForMoreInfo": "Napsauta saadaksesi lisätietoja",
|
||||
"LabelClickToUseCurrentValue": "Käytä nykyistä arvoa napsauttamalla",
|
||||
"LabelClosePlayer": "Sulje soitin",
|
||||
@ -310,8 +310,8 @@
|
||||
"LabelDurationComparisonLonger": "({0} pidempi)",
|
||||
"LabelDurationComparisonShorter": "({0} lyhyempi)",
|
||||
"LabelDurationFound": "Kesto löydetty:",
|
||||
"LabelEbook": "E-kirja",
|
||||
"LabelEbooks": "E-kirjat",
|
||||
"LabelEbook": "S-kirja",
|
||||
"LabelEbooks": "S-kirjat",
|
||||
"LabelEdit": "Muokkaa",
|
||||
"LabelEmail": "Sähköposti",
|
||||
"LabelEmailSettingsFromAddress": "Osoitteesta",
|
||||
@ -323,7 +323,7 @@
|
||||
"LabelEmbeddedCover": "Upotettu kansikuva",
|
||||
"LabelEnable": "Ota käyttöön",
|
||||
"LabelEncodingBackupLocation": "Alkuperäisistä audiotiedostoistasi tallennetaan varmuuskopio osoitteessa:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Lukuja ei upoteta moniraitaisiin äänikirjoihin.",
|
||||
"LabelEncodingChaptersNotEmbedded": "Lukuja ei ole upotettu moniraitaisiin äänikirjoihin.",
|
||||
"LabelEncodingClearItemCache": "Varmista, että kohteiden välimuisti tyhjennetään säännöllisesti.",
|
||||
"LabelEncodingFinishedM4B": "Valmistunut M4B tullaan viemään äänikirjakansioosi:",
|
||||
"LabelEncodingInfoEmbedded": "Kuvailutiedot upotetaan äänikirjakansion ääniraitoihin.",
|
||||
@ -340,6 +340,7 @@
|
||||
"LabelEpisodeType": "Jakson tyyppi",
|
||||
"LabelEpisodeUrlFromRssFeed": "Jakson URL RSS-syötteestä",
|
||||
"LabelEpisodes": "Jaksot",
|
||||
"LabelEpisodic": "Jaksollinen",
|
||||
"LabelExample": "Esimerkki",
|
||||
"LabelExpandSeries": "Laajenna sarja",
|
||||
"LabelExpandSubSeries": "Laajenna alisarja",
|
||||
@ -363,17 +364,22 @@
|
||||
"LabelFontItalic": "Kursiivi",
|
||||
"LabelFontScale": "Kirjasintyyppien skaalautuminen",
|
||||
"LabelFontStrikethrough": "Yliviivattu",
|
||||
"LabelFormat": "Muoto",
|
||||
"LabelFull": "Täynnä",
|
||||
"LabelGenre": "Lajityyppi",
|
||||
"LabelGenres": "Lajityypit",
|
||||
"LabelHasEbook": "Sillä on e-kirja",
|
||||
"LabelHasSupplementaryEbook": "Sillä on täydentävän e-kirjan",
|
||||
"LabelHardDeleteFile": "Kova tiedostojen poisto",
|
||||
"LabelHasEbook": "Sillä on s-kirja",
|
||||
"LabelHasSupplementaryEbook": "Sillä on täydentävän s-kirjan",
|
||||
"LabelHideSubtitles": "Piilota tekstitykset",
|
||||
"LabelHighestPriority": "Tärkein",
|
||||
"LabelHost": "Isäntä",
|
||||
"LabelHour": "Tunti",
|
||||
"LabelHours": "Tunnit",
|
||||
"LabelIcon": "Kuvake",
|
||||
"LabelImageURLFromTheWeb": "Kuvan verkko-osoite",
|
||||
"LabelInProgress": "Kesken",
|
||||
"LabelIncludeInTracklist": "Sisällytä kappalelistaan",
|
||||
"LabelIncomplete": "Keskeneräinen",
|
||||
"LabelInterval": "Väli",
|
||||
"LabelIntervalCustomDailyWeekly": "Mukautettu päivittäinen/viikoittainen",
|
||||
@ -384,6 +390,8 @@
|
||||
"LabelIntervalEvery6Hours": "6 tunnin välein",
|
||||
"LabelIntervalEveryDay": "Joka päivä",
|
||||
"LabelIntervalEveryHour": "Joka tunti",
|
||||
"LabelIntervalEveryMinute": "Joka minuutti",
|
||||
"LabelInvert": "Saa käänteiseksi",
|
||||
"LabelItem": "Kohde",
|
||||
"LabelLanguage": "Kieli",
|
||||
"LabelLanguageDefaultServer": "Palvelimen oletuskieli",
|
||||
@ -391,6 +399,7 @@
|
||||
"LabelLastBookAdded": "Viimeisin lisätty kirja",
|
||||
"LabelLastBookUpdated": "Viimeisin päivitetty kirja",
|
||||
"LabelLastSeen": "Nähty viimeksi",
|
||||
"LabelLastTime": "Viimeinen kerta",
|
||||
"LabelLastUpdate": "Viimeisin päivitys",
|
||||
"LabelLayout": "Asettelu",
|
||||
"LabelLayoutSinglePage": "Yksi sivu",
|
||||
@ -398,15 +407,21 @@
|
||||
"LabelLess": "Vähemmän",
|
||||
"LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot",
|
||||
"LabelLibrary": "Kirjasto",
|
||||
"LabelLibraryFilterSublistEmpty": "Ei {0}",
|
||||
"LabelLibraryItem": "Kirjaston kohde",
|
||||
"LabelLibraryName": "Kirjaston nimi",
|
||||
"LabelLimit": "Raja",
|
||||
"LabelLineSpacing": "Riviväli",
|
||||
"LabelListenAgain": "Kuuntele uudelleen",
|
||||
"LabelLogLevelDebug": "Viankorjaus",
|
||||
"LabelLogLevelInfo": "Tiedot",
|
||||
"LabelLogLevelWarn": "Varoitus",
|
||||
"LabelLookForNewEpisodesAfterDate": "Etsi uusia jaksoja tämän päivämäärän jälkeen",
|
||||
"LabelLowestPriority": "Vähiten tärkeä",
|
||||
"LabelMatchExistingUsersBy": "Vastaa olemassa olevia käyttäjiä mukaan",
|
||||
"LabelMatchExistingUsersByDescription": "Käytetään olemassa olevien käyttäjien yhdistämiseen. Kun yhteys on muodostettu, käyttäjät saavat yksilöllisen tunnuksen SSO-palveluntarjoajaltasi",
|
||||
"LabelMaxEpisodesToDownload": "Jaksojen maksimilatausmäärä. 0 poistaa rajoituksen.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Enintään # ladattavia uusia jaksoja tarkistusta kohden",
|
||||
"LabelMaxEpisodesToKeep": "Säilytettävien jaksojen enimmäismäärä",
|
||||
"LabelMaxEpisodesToKeepHelp": "Jos arvona on 0, enimmäisrajaa ei ole. Kun uusi jakso ladataan automaattisesti, vanhin jakso poistetaan, jos jaksoja on yli X. Tämä poistaa vain yhden jakson uutta latauskertaa kohden.",
|
||||
"LabelMediaPlayer": "Mediasoitin",
|
||||
@ -418,7 +433,10 @@
|
||||
"LabelMinute": "Minuutti",
|
||||
"LabelMinutes": "Minuutit",
|
||||
"LabelMissing": "Puuttuva",
|
||||
"LabelMissingEbook": "Ei e-kirjaa",
|
||||
"LabelMissingEbook": "Sillä ei ole s-kirjaa",
|
||||
"LabelMissingSupplementaryEbook": "Ei täydentävää s-kirjaa",
|
||||
"LabelMobileRedirectURIs": "Sallitut mobiiliuudelleenohjaus-URI:t",
|
||||
"LabelMobileRedirectURIsDescription": "Tämä on valkoluettelo kelvollisista uudelleenohjaus-URI:ista mobiilisovelluksille. Oletusarvo on <code>äänikirjahylly://oauth</code>, jonka voit poistaa tai täydentää ylimääräisillä URI:lla kolmannen osapuolen sovellusten integrointia varten. Asteriskin (<code>*</code>) käyttäminen ainoana merkintänä sallii minkä tahansa URI:n.",
|
||||
"LabelMore": "Lisää",
|
||||
"LabelMoreInfo": "Lisätietoja",
|
||||
"LabelName": "Nimi",
|
||||
@ -437,24 +455,34 @@
|
||||
"LabelNotes": "Muistiinpanoja",
|
||||
"LabelNotificationAppriseURL": "Apprise osoitteet (URL)",
|
||||
"LabelNotificationAvailableVariables": "Käytettävissä olevat muuttujat",
|
||||
"LabelNotificationBodyTemplate": "Runkomalli",
|
||||
"LabelNotificationEvent": "Ilmoitustapahtuma",
|
||||
"LabelNotificationTitleTemplate": "Otsikkomalli",
|
||||
"LabelNotificationsMaxFailedAttempts": "Epäonnistuneiden yritysten enimmäismäärä",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "Ilmoitukset poistetaan käytöstä, jos niiden lähettäminen epäonnistuu näin monta kertaa",
|
||||
"LabelNotificationsMaxQueueSize": "Ilmoitustapahtumajonon enimmäispituus",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Tapahtumat on rajoitettu ampumaan yksi sekunnissa. Tapahtumat ohitetaan, jos jono on enimmäiskoko. Tämä estää ilmoitusten roskapostin.",
|
||||
"LabelNumberOfBooks": "Kirjojen määrä",
|
||||
"LabelNumberOfEpisodes": "# jaksoja",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "OpenID-vaatimuksen nimi, joka sisältää lisäoikeudet sovelluksen käyttäjän toimiin, joita sovelletaan muihin kuin järjestelmänvalvojan rooleihin (<b>jos määritetty</b>). Jos vaatimus puuttuu vastauksesta, pääsy ABS:iin evätään. Jos yksittäinen vaihtoehto puuttuu, sitä käsitellään <code>false</code>-arvona. Varmista, että identiteetin tarjoajan vaatimus vastaa odotettua rakennetta:",
|
||||
"LabelOpenIDClaims": "Jätä seuraavat vaihtoehdot tyhjiksi, jos haluat poistaa edistyneen ryhmän ja lupien määrityksen käytöstä ja määrittää sitten automaattisesti käyttäjäryhmän.",
|
||||
"LabelOpenIDGroupClaimDescription": "Sen OpenID-vaatimuksen nimi, joka sisältää luettelon käyttäjäryhmistä. Kutsutaan yleisesti <code>ryhmiksi</code>. <b>Jos se on määritetty</b>, sovellus jakaa automaattisesti roolit käyttäjän ryhmäjäsenyyksien perusteella, jos näiden ryhmien nimet eivät erota kirjainkoosta \"admin\", \"user\" tai \"guest\" vaatimuksessa. Vaatimuksen tulee sisältää luettelo, ja jos käyttäjä kuuluu useisiin ryhmiin, sovellus määrittää korkeinta pääsytasoa vastaavan roolin. Jos mikään ryhmä ei täsmää, pääsy evätään.",
|
||||
"LabelOpenRSSFeed": "Avaa RSS-syöte",
|
||||
"LabelOverwrite": "Korvaa",
|
||||
"LabelPaginationPageXOfY": "Sivu {0}/{1}",
|
||||
"LabelPassword": "Salasana",
|
||||
"LabelPath": "Polku",
|
||||
"LabelPermanent": "Pysyvä",
|
||||
"LabelPermissionsAccessAllLibraries": "Käyttöoikeudet kaikkiin kirjastoihin",
|
||||
"LabelPermissionsAccessAllTags": "Saa käyttää kaikkia tunnisteita",
|
||||
"LabelPermissionsAccessAllTags": "On pääsy kaikkiin tunnisteihin",
|
||||
"LabelPermissionsAccessExplicitContent": "Saa käyttää aikuisille tarkoitettua sisältöä",
|
||||
"LabelPermissionsCreateEreader": "Voi luoda e-lukijan",
|
||||
"LabelPermissionsDelete": "Voi poistaa",
|
||||
"LabelPermissionsDownload": "Voi ladata",
|
||||
"LabelPermissionsUpdate": "Voi päivittää",
|
||||
"LabelPermissionsUpload": "Voi lähettää",
|
||||
"LabelPersonalYearReview": "Vuotesi katsauksessa ({0})",
|
||||
"LabelPhotoPathURL": "Valokuvan polku/URL-osoite",
|
||||
"LabelPlayMethod": "Toistotapa",
|
||||
"LabelPlayerChapterNumberMarker": "{0}/{1}",
|
||||
"LabelPlaylists": "Soittolistat",
|
||||
@ -463,53 +491,116 @@
|
||||
"LabelPodcastType": "Podcastien tyyppi",
|
||||
"LabelPodcasts": "Podcastit",
|
||||
"LabelPort": "Portti",
|
||||
"LabelPrefixesToIgnore": "Ohitettavat etuliitteet (kirjainkoolla ei väliä)",
|
||||
"LabelPreventIndexing": "Estä syötteesi olemasta iTunesin ja Googlen podcast-hakemistojen indeksoinnin kohteena",
|
||||
"LabelPrimaryEbook": "Ensisijainen e-kirja",
|
||||
"LabelPrimaryEbook": "Ensisijainen s-kirja",
|
||||
"LabelProgress": "Edistyminen",
|
||||
"LabelProvider": "Toimittaja",
|
||||
"LabelProviderAuthorizationValue": "Valtuutusotsikon arvo",
|
||||
"LabelPubDate": "Julkaisupäivä",
|
||||
"LabelPublishYear": "Julkaisuvuosi",
|
||||
"LabelPublishedDate": "Julkaistu {0}",
|
||||
"LabelPublishedDecade": "Julkaistu vuosikymmen",
|
||||
"LabelPublishedDecades": "Julkaistu vuosikymmenet",
|
||||
"LabelPublisher": "Julkaisija",
|
||||
"LabelPublishers": "Julkaisijat",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Mukautettu omistajan sähköposti",
|
||||
"LabelRSSFeedCustomOwnerName": "Mukautettu omistajan nimi",
|
||||
"LabelRSSFeedOpen": "RSS-syöte avoin",
|
||||
"LabelRSSFeedPreventIndexing": "Estä indeksointi",
|
||||
"LabelRSSFeedSlug": "RSS-syöte Slug",
|
||||
"LabelRSSFeedURL": "RSS-syötteen URL-osoite",
|
||||
"LabelRandomly": "Satunnaisesti",
|
||||
"LabelReAddSeriesToContinueListening": "Lisää sarja uudelleen jatkaaksesi kuuntelua",
|
||||
"LabelRead": "Lue",
|
||||
"LabelReadAgain": "Lue uudelleen",
|
||||
"LabelReadEbookWithoutProgress": "Lue e-kirja tallentamatta edistymistietoja",
|
||||
"LabelReadEbookWithoutProgress": "Lue s-kirja tallentamatta edistymistietoja",
|
||||
"LabelRecentSeries": "Viimeisimmät sarjat",
|
||||
"LabelRecentlyAdded": "Viimeeksi lisätyt",
|
||||
"LabelRecommended": "Suositeltu",
|
||||
"LabelRedo": "Tee uudelleen",
|
||||
"LabelRegion": "Alue",
|
||||
"LabelReleaseDate": "Julkaisupäivä",
|
||||
"LabelRemoveAllMetadataAbs": "Poista kaikki metadata.abs-tiedostot",
|
||||
"LabelRemoveAllMetadataJson": "Poista kaikki metadata.json-tiedostot",
|
||||
"LabelRemoveCover": "Poista kansikuva",
|
||||
"LabelRemoveMetadataFile": "Poista metatietotiedostot kirjaston kohdekansioista",
|
||||
"LabelRemoveMetadataFileHelp": "Poista kaikki metadata.json- ja metadata.abs-tiedostot {0} kansiostasi.",
|
||||
"LabelRowsPerPage": "Rivejä sivulla",
|
||||
"LabelSearchTerm": "Hakusana",
|
||||
"LabelSearchTitle": "Etsi otsikko",
|
||||
"LabelSearchTitleOrASIN": "Etsi otsikko tai ASIN",
|
||||
"LabelSeason": "Kausi",
|
||||
"LabelSeasonNumber": "Kausi #{0}",
|
||||
"LabelSelectAll": "Valitse kaikki",
|
||||
"LabelSelectAllEpisodes": "Valitse kaikki jaksot",
|
||||
"LabelSelectEpisodesShowing": "Valitse {0} näytettävää jaksoa",
|
||||
"LabelSelectUsers": "Valitse käyttäjät",
|
||||
"LabelSendEbookToDevice": "Lähetä s-kirja kohteeseen...",
|
||||
"LabelSequence": "Sekvenssi",
|
||||
"LabelSerial": "Sarja",
|
||||
"LabelSeries": "Sarja",
|
||||
"LabelSeriesName": "Sarjan nimi",
|
||||
"LabelSeriesProgress": "Sarjan edistyminen",
|
||||
"LabelServerLogLevel": "Palvelimen lokitaso",
|
||||
"LabelServerYearReview": "Palvelimen vuosi katsauksessa ({0})",
|
||||
"LabelSetEbookAsPrimary": "Aseta ensisijaiseksi",
|
||||
"LabelSetEbookAsSupplementary": "Aseta täydentäväksi",
|
||||
"LabelSettingsAllowIframe": "Salli upottaminen iframe-kehykseen",
|
||||
"LabelSettingsAudiobooksOnly": "Vain äänikirjat",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Tämän asetuksen käyttöönotto ohittaa s-kirjatiedostot, elleivät ne ole äänikirjakansiossa, jolloin ne asetetaan täydentäviksi s-kirjoiksi",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeuomorfinen muotoilu puisilla hyllyillä",
|
||||
"LabelSettingsChromecastSupport": "Chromecast-tuki",
|
||||
"LabelSettingsDateFormat": "Päivämäärän muoto",
|
||||
"LabelSettingsEnableWatcherHelp": "Ottaa käyttöön kohteiden automaattisen lisäämisen ja päivityksen kun tiedostomuutoksia havaitaan. *Tarvitsee palvelimen uudelleenkäynnistyksen",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Salli komentosarjamuotoinen sisältö epubissa",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Salli epub-tiedostojen suorittaa komentosarjoja. On suositeltavaa pitää tämä asetus pois käytöstä, ellet luota epub-tiedostojen lähteeseen.",
|
||||
"LabelSettingsExperimentalFeatures": "Kokeelliset ominaisuudet",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Kehitettävissä olevat ominaisuudet, jotka voivat hyödyntää palautettasi ja auttaa testaamisessa. Napsauta avataksesi github-keskustelun.",
|
||||
"LabelSettingsFindCovers": "Etsi kansikuvia",
|
||||
"LabelSettingsFindCoversHelp": "Jos äänikirjassasi ei ole kansion sisällä upotettua kantta tai kansikuvaa, skanneri yrittää löytää kannen.<br>Huomaa: Tämä pidentää skannausaikaa",
|
||||
"LabelSettingsHideSingleBookSeries": "Piilota yksittäinen kirjasarja",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Sarjat, joissa on yksi kirja, piilotetaan sarjasivulta ja kotisivujen hyllyiltä.",
|
||||
"LabelSettingsHomePageBookshelfView": "Kotisivu käyttää kirjahyllynäkymää",
|
||||
"LabelSettingsLibraryBookshelfView": "Kirjasto käyttää kirjahyllynäkymää",
|
||||
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Valmistumisprosentti on suurempi kuin",
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Jäljellä oleva aika on alle (sekuntia)",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Merkitse mediakohde valmiiksi, kun",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Ohita aiemmat kirjat Jatka sarjassa",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Jatka sarja -kotisivun hyllyssä näkyy ensimmäinen kirja, jota ei ole aloitettu sarjoissa, joissa on vähintään yksi kirja valmiina eikä yhtään kirjaa kesken. Tämän asetuksen ottaminen käyttöön jatkaa sarjaa kauimpana valmistuneesta kirjasta ensimmäisen aloittamattoman kirjan sijaan.",
|
||||
"LabelSettingsParseSubtitles": "Jäsennä tekstitykset",
|
||||
"LabelSettingsParseSubtitlesHelp": "Pura tekstitykset äänikirjojen kansioiden nimistä.<br>Tekstitys on erotettava toisistaan merkillä \"-\"<br>ts. \"Kirjan otsikko - Tekstitys täällä\" on alaotsikko \"Tekstitys täällä\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Pidä mieluummin täsmäävät metatiedot",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Täsmäävät tiedot ohittavat kohteen tiedot käytettäessä Pikatäsmäystä. Oletuksena Pikatäsmäys täyttää vain puuttuvat tiedot.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Ohita täsmäävät kirjat, joilla on jo ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Ohita täsmäävät kirjat, joilla on jo ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Jätä etuliitteet huomioimatta lajittelussa",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "eli etuliitteelle \"tämän\" kirjan nimi \"Tämän kirjan nimi\" lajitellaan muodossa \"Kirjan nimi, Tämän\"",
|
||||
"LabelSettingsSquareBookCovers": "Käytä neliömäisiä kirjankansia",
|
||||
"LabelSettingsSquareBookCoversHelp": "Käytä mieluummin neliömäisiä kansia kuin tavallisia 1,6:1 kirjankansia",
|
||||
"LabelSettingsStoreCoversWithItem": "Säilytyskannet esineen kanssa",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Oletusarvoisesti kannet tallennetaan kansioon /metadata/items, ja tämän asetuksen ottaminen käyttöön tallentaa kannet kirjaston kohdekansioon. Vain yksi tiedosto nimeltä \"cover\" säilytetään",
|
||||
"LabelSettingsStoreMetadataWithItem": "Tallenna metatiedot kohteen kanssa",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Oletuksena metatietotiedostot tallennetaan kansioon /metadata/items, ja tämän asetuksen ottaminen käyttöön tallentaa metatietotiedostot kirjastosi kohdekansioihin",
|
||||
"LabelSettingsTimeFormat": "Aikamuoto",
|
||||
"LabelShare": "Jaa",
|
||||
"LabelShareDownloadableHelp": "Antaa käyttäjien, joilla on jakolinkki, ladata kirjastokohteen zip-tiedoston.",
|
||||
"LabelShareOpen": "Jaa Avoin",
|
||||
"LabelShareURL": "Jaa URL-osoite",
|
||||
"LabelShowAll": "Näytä kaikki",
|
||||
"LabelShowSeconds": "Näytä sekunnit",
|
||||
"LabelShowSubtitles": "Näytä tekstitykset",
|
||||
"LabelSize": "Koko",
|
||||
"LabelSleepTimer": "Uniajastin",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Nouseva",
|
||||
"LabelSortDescending": "Laskeva",
|
||||
"LabelStart": "Aloita",
|
||||
"LabelStartTime": "Aloitusaika",
|
||||
"LabelStarted": "Aloitettu",
|
||||
"LabelStartedAt": "Aloitettu",
|
||||
"LabelStatsAudioTracks": "Ääniraidat",
|
||||
"LabelStatsAuthors": "Tekijät",
|
||||
"LabelStatsBestDay": "Paras päivä",
|
||||
"LabelStatsDailyAverage": "Päivittäinen keskiarvo",
|
||||
"LabelStatsDays": "Päivää",
|
||||
@ -517,36 +608,175 @@
|
||||
"LabelStatsHours": "Tunnit",
|
||||
"LabelStatsInARow": "peräjälkeen",
|
||||
"LabelStatsItemsFinished": "Valmiit tuotteet",
|
||||
"LabelStatsItemsInLibrary": "Kohteet kirjastossa",
|
||||
"LabelStatsMinutes": "minuuttia",
|
||||
"LabelStatsMinutesListening": "Minuuttia kuunneltu",
|
||||
"LabelStatsOverallDays": "Päivät kokonaisuudessaan",
|
||||
"LabelStatsOverallHours": "Tunnit kokonaisuudessaan",
|
||||
"LabelStatsWeekListening": "Viikon aikana kuunneltu",
|
||||
"LabelSubtitle": "Tekstitys",
|
||||
"LabelSupportedFileTypes": "Tuetut tiedostotyypit",
|
||||
"LabelTag": "Tägi",
|
||||
"LabelTags": "Tägit",
|
||||
"LabelTagsAccessibleToUser": "Tunnisteet käyttäjän käytettävissä",
|
||||
"LabelTagsNotAccessibleToUser": "Tunnisteet ei käyttäjien käytettävissä",
|
||||
"LabelTasks": "Tehtävät käynnissä",
|
||||
"LabelTextEditorBulletedList": "Luettelomerkitty luettelo",
|
||||
"LabelTextEditorLink": "Linkki",
|
||||
"LabelTextEditorNumberedList": "Numeroitu luettelo",
|
||||
"LabelTextEditorUnlink": "Poista linkitys",
|
||||
"LabelTheme": "Teema",
|
||||
"LabelThemeDark": "Tumma",
|
||||
"LabelThemeLight": "Kirkas",
|
||||
"LabelTimeBase": "Aika-alusta",
|
||||
"LabelTimeDurationXHours": "{0} tuntia",
|
||||
"LabelTimeDurationXMinutes": "{0} minuuttia",
|
||||
"LabelTimeDurationXSeconds": "{0} sekuntia",
|
||||
"LabelTimeInMinutes": "Aika minuutteina",
|
||||
"LabelTimeLeft": "{0} jäljellä",
|
||||
"LabelTimeListened": "Aika kuunneltu",
|
||||
"LabelTimeListenedToday": "Kuunneltu aika tänään",
|
||||
"LabelTimeRemaining": "{0} jäljellä",
|
||||
"LabelTimeToShift": "Vaihtoaika sekunteina",
|
||||
"LabelTitle": "Nimi",
|
||||
"LabelToolsEmbedMetadata": "Upota metatiedot",
|
||||
"LabelToolsEmbedMetadataDescription": "Upota metatiedot äänitiedostoihin, mukaan lukien kansikuva ja luvut.",
|
||||
"LabelToolsM4bEncoder": "M4B Enkooderi",
|
||||
"LabelToolsMakeM4b": "Tee M4B-äänikirjatiedosto",
|
||||
"LabelToolsMakeM4bDescription": "Luo .M4B-äänikirjatiedosto, joka sisältää upotetut metatiedot, kansikuvan ja luvut.",
|
||||
"LabelToolsSplitM4b": "Jaa M4B MP3:ksi",
|
||||
"LabelToolsSplitM4bDescription": "Luo MP3-tiedostoja M4B:stä, jaettuna lukujen mukaan, upotetulla metatiedolla, kansikuvalla ja luvuilla.",
|
||||
"LabelTotalDuration": "Kokonaiskesto",
|
||||
"LabelTotalTimeListened": "Yhteensä kuunneltu aika",
|
||||
"LabelTrackFromFilename": "Raita tiedostonimestä",
|
||||
"LabelTrackFromMetadata": "Raita metatiedoista",
|
||||
"LabelTracks": "Raidat",
|
||||
"LabelTracksMultiTrack": "Moniraitainen",
|
||||
"LabelTracksNone": "Ei raitoja",
|
||||
"LabelTracksSingleTrack": "Yksiraitainen",
|
||||
"LabelTrailer": "Traileri",
|
||||
"LabelType": "Tyyppi",
|
||||
"LabelUnabridged": "Lyhentämätön",
|
||||
"LabelUndo": "Kumoa",
|
||||
"LabelUnknown": "Tuntematon",
|
||||
"LabelUnknownPublishDate": "Tuntematon julkaisupäivämäärä",
|
||||
"LabelUpdateCover": "Päivitä kansikuva",
|
||||
"LabelUpdateCoverHelp": "Salli valittujen kirjojen olemassa olevien kansien päällekirjoittaminen, kun osuma löytyy",
|
||||
"LabelUpdateDetails": "Päivitä yksityiskohdat",
|
||||
"LabelUpdateDetailsHelp": "Salli valittujen kirjojen olemassa olevien tietojen korvaaminen, kun osuma löytyy",
|
||||
"LabelUpdatedAt": "Päivitetty",
|
||||
"LabelUploaderDragAndDrop": "Vedä ja pudota tiedostoja tai kansioita",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Vedä ja pudota tiedostoja",
|
||||
"LabelUploaderDropFiles": "Pudota tiedostot",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Nouda automaattisesti otsikko, tekijä ja sarja",
|
||||
"LabelUseAdvancedOptions": "Käytä edistyneitä vaihtoehtoja",
|
||||
"LabelUseChapterTrack": "Käytä luvunraitaa",
|
||||
"LabelUseFullTrack": "Käytä täyttä raitaa",
|
||||
"LabelUseZeroForUnlimited": "Käytä 0 rajatonta varten",
|
||||
"LabelUser": "Käyttäjä",
|
||||
"LabelUsername": "Käyttäjätunnus",
|
||||
"LabelValue": "Arvo",
|
||||
"LabelVersion": "Versio",
|
||||
"LabelViewBookmarks": "Katso kirjanmerkit",
|
||||
"LabelViewChapters": "Katso luvut",
|
||||
"LabelViewPlayerSettings": "Katso soittimen asetukset",
|
||||
"LabelViewQueue": "Katso soittimen jono",
|
||||
"LabelVolume": "Äänenvoimakkuus",
|
||||
"LabelWebRedirectURLsDescription": "Valtuuta nämä URL-osoitteet OAuth-palveluntarjoajassasi sallimaan uudelleenohjauksen takaisin verkkosovellukseen sisäänkirjautumisen jälkeen:",
|
||||
"LabelWebRedirectURLsSubfolder": "Alikansio URL-osoitteiden uudelleenohjaukselle",
|
||||
"LabelWeekdaysToRun": "Ajettavat arkipäivät",
|
||||
"LabelXBooks": "{0} kirjaa",
|
||||
"LabelXItems": "{0} kohdetta",
|
||||
"LabelYearReviewHide": "Piilota vuosi arvostelussa",
|
||||
"LabelYearReviewShow": "Näytä vuosi arvostelussa",
|
||||
"LabelYourAudiobookDuration": "Äänikirjan kesto",
|
||||
"LabelYourBookmarks": "Kirjanmerkkisi",
|
||||
"LabelYourPlaylists": "Soittolistasi",
|
||||
"LabelYourProgress": "Edistymisesi",
|
||||
"MessageAddToPlayerQueue": "Lisää soittimen jonoon",
|
||||
"MessageAppriseDescription": "Käyttääksesi tätä toimintoa tarvitset <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> -instanssin tai rajapinnan joka käsittelee samoja pyyntöjä. <br />Apprise rajapinnan osoite tulee olla täysi URL polku ilmoituksen lähetykseen, esim. jos rajapinta on osoitteessa <code>http://192.168.1.1:8337</code>,niin arvoksi tulee antaa <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Varmuuskopiot sisältävät käyttäjät, käyttäjien edistymisen, kirjastokohteiden tiedot, palvelinasetukset ja <code>/metadata/items</code>- ja <code>/metadata/authors</code> -kansioihin tallennetut kuvat. Varmuuskopiot <strong>eivät sisällä</strong> kirjastosi kansioihin tallennettuja tiedostoja.",
|
||||
"MessageBackupsLocationEditNote": "Huomautus: Varmuuskopion sijainnin päivittäminen ei siirrä tai muokkaa olemassa olevia varmuuskopioita",
|
||||
"MessageBackupsLocationNoEditNote": "Huomautus: Varmuuskopion sijainti asetetaan ympäristömuuttujan kautta, eikä sitä voi muuttaa tässä.",
|
||||
"MessageBackupsLocationPathEmpty": "Varmuuskopiointisijainnin polku ei voi olla tyhjä",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Täytä käytössä olevat kentät tiedoilla kaikista kohteista. Kentät, joilla on useita arvoja, yhdistetään",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Täytä käytössä olevat karttayksityiskohtakentät tämän kohteen tiedoilla",
|
||||
"MessageBatchQuickMatchDescription": "Pikatäsmäys yrittää lisätä puuttuvat kannet ja metatiedot valituille kohteille. Ota käyttöön alla olevat vaihtoehdot, jotta Pikatäsmäys korvaa olemassa olevat kannet ja/tai metatiedot.",
|
||||
"MessageBookshelfNoCollections": "Et ole vielä tehnyt kokoelmia",
|
||||
"MessageBookshelfNoCollectionsHelp": "Kokoelmat ovat julkisia. Kaikki käyttäjät, joilla on pääsy kirjastoon, voivat nähdä ne.",
|
||||
"MessageBookshelfNoRSSFeeds": "RSS-syötteitä ei ole auki",
|
||||
"MessageBookshelfNoResultsForFilter": "Ei tuloksia suodattimelle \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Ei tuloksia kyselylle",
|
||||
"MessageBookshelfNoSeries": "Sinulla ei ole sarjoja",
|
||||
"MessageChapterEndIsAfter": "Luvun loppu sijaitsee äänikirjan lopun jälkeen",
|
||||
"MessageChapterErrorFirstNotZero": "Ensimmäisen luvun tulee alkaa nollasta",
|
||||
"MessageChapterErrorStartGteDuration": "Epäkelvollinen aloitusaika; on oltava lyhyempi kuin äänikirjan kesto",
|
||||
"MessageChapterErrorStartLtPrev": "Epäkelvollinen aloitusaika; on oltava suurempi tai yhtä suuri kuin edellisen luvun aloitusaika",
|
||||
"MessageChapterStartIsAfter": "Luku alkaa äänikirjan lopun jälkeen",
|
||||
"MessageCheckingCron": "Tarkistetaan cronia...",
|
||||
"MessageConfirmCloseFeed": "Oletko varma, että haluat sulkea tämän syötteen?",
|
||||
"MessageConfirmDeleteBackup": "Oletko varma, että haluat poistaa varmuuskopion {0}:lle?",
|
||||
"MessageConfirmDeleteDevice": "Oletko varma, että haluat poistaa s-lukulaitteen \"{0}\"?",
|
||||
"MessageConfirmDeleteFile": "Tämä poistaa tiedoston tiedostojärjestelmästäsi. Oletko varma?",
|
||||
"MessageConfirmDeleteLibrary": "Oletko varma, että haluat poistaa kirjaston \"{0}\" pysyvästi?",
|
||||
"MessageConfirmDeleteLibraryItem": "Tämä poistaa kirjastokohteen tietokannasta ja tiedostojärjestelmästäsi. Oletko varma?",
|
||||
"MessageConfirmDeleteLibraryItems": "Tämä poistaa {0} kirjastokohdetta tietokannasta ja tiedostojärjestelmästäsi. Oletko varma?",
|
||||
"MessageConfirmDeleteMetadataProvider": "Oletko varma, että haluat poistaa mukautetun metatietojen tarjoajan \"{0}\"?",
|
||||
"MessageConfirmDeleteNotification": "Oletko varma, että haluat poistaa tämän ilmoituksen?",
|
||||
"MessageConfirmDeleteSession": "Oletko varma, että haluat poistaa tämän istunnon?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Oletko varma, että haluat upottaa metatiedot {0} äänitiedostoihin?",
|
||||
"MessageConfirmForceReScan": "Oletko varma, että haluat pakottaa uudelleenskannauksen?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Oletko varma, että haluat merkitä kaikki jaksot päättyneiksi?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Oletko varma, että haluat merkitä kaikki jaksot ei-valmiiksi?",
|
||||
"MessageConfirmMarkItemFinished": "Oletko varma, että haluat merkitä \"{0}\":n valmiiksi?",
|
||||
"MessageConfirmMarkItemNotFinished": "Oletko varma, että haluat merkitä \"{0}\":n ei-valmiiksi?",
|
||||
"MessageConfirmMarkSeriesFinished": "Oletko varma, että haluat merkitä kaikki tämän sarjan kirjat valmiiksi?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Oletko varma, että haluat merkitä kaikki tämän sarjan kirjat ei-valmiiksi?",
|
||||
"MessageConfirmNotificationTestTrigger": "Käynnistetäänkö tämä ilmoitus testitiedoilla?",
|
||||
"MessageConfirmPurgeCache": "'Tyhjennä välimuisti' poistaa koko hakemiston sijainnilla <code>/metadata/cache</code>. <br /><br />Oletko varma, että haluat poistaa välimuistihakemiston?",
|
||||
"MessageConfirmPurgeItemsCache": "'Tyhjennä kohteiden välimuisti' poistaa koko hakemiston sijainnilla <code>/metadata/cache/items</code>.<br />Oletko varma?",
|
||||
"MessageConfirmQuickEmbed": "Varoitus! Pikaupottaminen ei varmuuskopioi äänitiedostojasi. Varmista, että sinulla on varmuuskopio äänitiedostoistasi. <br><br>Haluatko jatkaa?",
|
||||
"MessageConfirmQuickMatchEpisodes": "Jaksojen pikatäsmääminen korvaa tiedot, jos vastaavuus löytyy. Vain täsmäämättömät jaksot päivitetään. Oletko varma?",
|
||||
"MessageConfirmReScanLibraryItems": "Oletko varma, että haluat skannata uudelleen {0} kohdetta?",
|
||||
"MessageConfirmRemoveAllChapters": "Oletko varma, että haluat poistaa kaikki jaksot?",
|
||||
"MessageConfirmRemoveAuthor": "Oletko varma, että haluat poistaa tekijän \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Oletko varma, että haluat poistaa kokoelman \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Oletko varma, että haluat poistaa jakson \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Oletko varma, että haluat poistaa {0} jaksoa?",
|
||||
"MessageConfirmRemoveListeningSessions": "Oletko varma, että haluat poistaa {0} kuuntelukertaa?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Oletko varma, että haluat poistaa kaikki metadata.{0}-tiedostot kirjaston kohdekansioista?",
|
||||
"MessageConfirmRemoveNarrator": "Oletko varma, että haluat poistaa kertojan \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Oletko varma, että haluat poistaa soittolistan \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Oletko varma, että haluat nimetä lajityypin \"{0}\" uudelleen \"{1}\":ksi kaikille kohteille?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Huomautus: Tämä lajityyppi on jo olemassa, joten ne yhdistetään.",
|
||||
"MessageConfirmRenameGenreWarning": "Varoitus! Samanlainen lajityyppi eri kotelolla on jo olemassa \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Oletko varma, että haluat nimetä tunnisteen \"{0}\" uudelleen \"{1}\":ksi kaikille kohteille?",
|
||||
"MessageConfirmRenameTagMergeNote": "Huomautus: Tämä tunniste on jo olemassa, joten ne yhdistetään.",
|
||||
"MessageConfirmRenameTagWarning": "Varoitus! Samanlainen tunniste eri kotelolla on jo olemassa \"{0}\".",
|
||||
"MessageConfirmResetProgress": "Oletko varma, että haluat nollata edistymisesi?",
|
||||
"MessageConfirmSendEbookToDevice": "Oletko varma, että haluat lähettää {0} s-kirjan \"{1}\" laitteeseen \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Oletko varma, että haluat poistaa tämän käyttäjän linkityksen OpenID:stä?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} kuunneltua päivää viime vuonna",
|
||||
"MessageDownloadingEpisode": "Ladataan jaksoa",
|
||||
"MessageDragFilesIntoTrackOrder": "Vedä tiedostot oikeaan raitojen järjestykseen",
|
||||
"MessageEmbedFailed": "Upotus epäonnistui!",
|
||||
"MessageEmbedFinished": "Upotus valmis!",
|
||||
"MessageEmbedQueue": "Jonossa metatietojen upottamista varten ({0} jonossa)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} jaksoa on latausjonossa",
|
||||
"MessageEreaderDevices": "S-kirjojen toimituksen varmistamiseksi sinun on ehkä lisättävä yllä oleva sähköpostiosoite kelvolliseksi lähettäjäksi jokaiselle alla luetellulle laitteelle.",
|
||||
"MessageFeedURLWillBe": "Syötteen URL tulee olemaan {0}",
|
||||
"MessageFetching": "Haetaan...",
|
||||
"MessageForceReScanDescription": "skannaa kaikki tiedostot uudelleen kuten uusi tarkistus. Äänitiedoston ID3-tunnisteet, OPF-tiedostot ja tekstitiedostot skannataan uusina.",
|
||||
"MessageImportantNotice": "Tärkeä huomautus!",
|
||||
"MessageInsertChapterBelow": "Syötä luku alle",
|
||||
"MessageItemsSelected": "{0} kohdetta valittu",
|
||||
"MessageItemsUpdated": "{0} kohdetta päivitetty",
|
||||
"MessageJoinUsOn": "Liity meihin",
|
||||
"MessageLoading": "Ladataan...",
|
||||
"MessageLoadingFolders": "Ladataan kansioita...",
|
||||
"MessageLogsDescription": "Lokitiedot tallennetaan kansioon <code>/metadata/logs</code> JSON-tiedostoina. Kaatumislokit tallennetaan kansioon <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
"MessageM4BFailed": "M4B epäonnistui!",
|
||||
"MessageM4BFinished": "M4B valmis!",
|
||||
"MessageMarkAsFinished": "Merkitse valmiiksi",
|
||||
"MessageNoBookmarks": "Ei kirjanmerkkejä",
|
||||
"MessageNoChapters": "Ei kappaleita",
|
||||
@ -561,7 +791,11 @@
|
||||
"MessageNoUserPlaylists": "Sinulla ei ole soittolistoja",
|
||||
"MessageOr": "tai",
|
||||
"MessagePodcastSearchField": "Syötä hakutermi tai RSS-syötteen URL-osoite",
|
||||
"MessageQuickMatchAllEpisodes": "Pikatäsmää kaikki jaksot",
|
||||
"MessageRemoveUserWarning": "Oletko varma, että haluat poistaa käyttäjän \"{0}\" pysyvästi?",
|
||||
"MessageReportBugsAndContribute": "Ilmoita virheistä, toivo ominaisuuksia ja osallistu",
|
||||
"MessageResetChaptersConfirm": "Oletko varma, että haluat nollata luvut ja kumota tekemäsi muutokset?",
|
||||
"MessageRestoreBackupConfirm": "Oletko varma, että haluat palauttaa varmuuskopion, joka on luotu",
|
||||
"MessageScheduleLibraryScanNote": "Suurimmalle osaa käyttäjistä on suositeltavaa jättää tämä ominaisuus pois päältä ja säilyttää kansiotarkkailu päällä. Kansiotarkkailu havaitsee automaattisesti tiedostomuutokset kirjaston kansioissa. Kansiotarkkailu ei toimi kaikille tiedostojärjestelmille (kuten NFS), jolloin voidaan käyttää ajastettuja kirjastoskannauksia.",
|
||||
"MessageTaskFailed": "Epäonnistunut",
|
||||
"MessageWatcherIsDisabledGlobally": "Kansiotarkkailu on poistettu käytöstä kaikkialla palvelimen asetuksissa",
|
||||
@ -570,6 +804,8 @@
|
||||
"StatsSessions": "istunnot",
|
||||
"ToastAccountUpdateSuccess": "Tili päivitetty",
|
||||
"ToastAppriseUrlRequired": "Arvon tulee olla Apprise URL",
|
||||
"ToastBatchQuickMatchFailed": "Erän pikatäsmäys epäonnistui!",
|
||||
"ToastBatchQuickMatchStarted": "{0} kirjan erän pikatäsmäys aloitettu!",
|
||||
"ToastBookmarkCreateFailed": "Kirjanmerkin luominen epäonnistui",
|
||||
"ToastCoverUpdateFailed": "Kansikuvan päivitys epäonnistui",
|
||||
"ToastItemCoverUpdateSuccess": "Kohteen kansikuva päivitetty",
|
||||
|
@ -76,7 +76,7 @@
|
||||
"ButtonReadLess": "Lire moins",
|
||||
"ButtonReadMore": "Lire la suite",
|
||||
"ButtonRefresh": "Rafraîchir",
|
||||
"ButtonRemove": "Supprimer",
|
||||
"ButtonRemove": "Retirer",
|
||||
"ButtonRemoveAll": "Supprimer tout",
|
||||
"ButtonRemoveAllLibraryItems": "Supprimer tous les éléments de la bibliothèque",
|
||||
"ButtonRemoveFromContinueListening": "Ne plus continuer à écouter",
|
||||
|
@ -252,7 +252,7 @@
|
||||
"LabelBackToUser": "Povratak na korisnika",
|
||||
"LabelBackupAudioFiles": "Sigurnosno kopiranje zvučnih datoteka",
|
||||
"LabelBackupLocation": "Lokacija sigurnosnih kopija",
|
||||
"LabelBackupsEnableAutomaticBackups": "Omogući automatsku izradu sigurnosnih kopija",
|
||||
"LabelBackupsEnableAutomaticBackups": "Automatske sigurnosne kopije",
|
||||
"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.",
|
||||
@ -403,8 +403,8 @@
|
||||
"LabelLanguages": "Jezici",
|
||||
"LabelLastBookAdded": "Zadnja dodana knjiga",
|
||||
"LabelLastBookUpdated": "Zadnja ažurirana knjiga",
|
||||
"LabelLastSeen": "Zadnje gledano",
|
||||
"LabelLastTime": "Vrijeme zadnjeg slušanja",
|
||||
"LabelLastSeen": "Zadnji puta viđen",
|
||||
"LabelLastTime": "Zadnje doslušano vrijeme",
|
||||
"LabelLastUpdate": "Zadnje ažuriranje",
|
||||
"LabelLayout": "Prikaz",
|
||||
"LabelLayoutSinglePage": "Jedna stranica",
|
||||
@ -558,6 +558,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama",
|
||||
"LabelSettingsChromecastSupport": "Podrška za Chromecast",
|
||||
"LabelSettingsDateFormat": "Format datuma",
|
||||
"LabelSettingsEnableWatcher": "Automatski pretražuj ima li promjena u knjižnicama",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Automatski traži promjene u knjižnicama",
|
||||
"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.",
|
||||
|
@ -1,3 +1,21 @@
|
||||
{
|
||||
"ButtonAdd": "追加"
|
||||
"ButtonAdd": "追加",
|
||||
"ButtonAddChapters": "チャプターの追加",
|
||||
"ButtonCancel": "キャンセル",
|
||||
"ButtonOk": "はい",
|
||||
"ButtonPlay": "プレイ",
|
||||
"ButtonPlaying": "プレイ中",
|
||||
"ButtonPrevious": "先",
|
||||
"ButtonRead": "野村",
|
||||
"ButtonYes": "はい",
|
||||
"HeaderPlayerSettings": "プレーヤーの設定",
|
||||
"LabelBooks": "ほん",
|
||||
"LabelLanguage": "言語",
|
||||
"LabelLanguages": "言語",
|
||||
"LabelName": "名",
|
||||
"LabelNew": "新しい",
|
||||
"LabelNewPassword": "新しいのパスワード",
|
||||
"LabelPassword": "パスワード",
|
||||
"LabelPlaylists": "プレイリスト",
|
||||
"LabelPodcast": "ポッドキャスト"
|
||||
}
|
||||
|
93
client/strings/sk.json
Normal file
93
client/strings/sk.json
Normal file
@ -0,0 +1,93 @@
|
||||
{
|
||||
"ButtonAdd": "Pridať",
|
||||
"ButtonAddChapters": "Pridať kapitoly",
|
||||
"ButtonAddDevice": "Pridať zariadenie",
|
||||
"ButtonAddLibrary": "Pridať knižnicu",
|
||||
"ButtonAddPodcasts": "Pridať podcasty",
|
||||
"ButtonAddUser": "Pridať užívateľa",
|
||||
"ButtonAddYourFirstLibrary": "Pridajte vašu prvú knižnicu",
|
||||
"ButtonApply": "Použiť",
|
||||
"ButtonApplyChapters": "Použiť kapitoly",
|
||||
"ButtonAuthors": "Autori",
|
||||
"ButtonBack": "Späť",
|
||||
"ButtonBatchEditPopulateFromExisting": "Vytvoriť z existujúcej",
|
||||
"ButtonBatchEditPopulateMapDetails": "Vyplniť detaily na mape",
|
||||
"ButtonBrowseForFolder": "Prehľadávať adresáre",
|
||||
"ButtonCancel": "Zrušiť",
|
||||
"ButtonCancelEncode": "Zrušiť kódovanie",
|
||||
"ButtonChangeRootPassword": "Zmeniť Root heslo",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Skontrolovať a stiahnuť nové epizódy",
|
||||
"ButtonChooseAFolder": "Vyberte adresár",
|
||||
"ButtonChooseFiles": "Vyberte súbory",
|
||||
"ButtonClearFilter": "Zrušiť filter",
|
||||
"ButtonCloseFeed": "Zatvoriť zdroj",
|
||||
"ButtonCloseSession": "Ukončiť otvorené pripojenie",
|
||||
"ButtonCollections": "Zbierky",
|
||||
"ButtonConfigureScanner": "Nastaviť skener",
|
||||
"ButtonCreate": "Vytvoriť",
|
||||
"ButtonCreateBackup": "Vytvoriť zálohu",
|
||||
"ButtonDelete": "Zmazať",
|
||||
"ButtonDownloadQueue": "Poradie",
|
||||
"ButtonEdit": "Upraviť",
|
||||
"ButtonEditChapters": "Upraviť kapitoly",
|
||||
"ButtonEditPodcast": "Upraviť podcast",
|
||||
"ButtonEnable": "Povoliť",
|
||||
"ButtonForceReScan": "Vynútiť preskenovanie",
|
||||
"ButtonFullPath": "Zobraziť cestu",
|
||||
"ButtonHide": "Skryť",
|
||||
"ButtonHome": "Domov",
|
||||
"ButtonIssues": "Problémy",
|
||||
"ButtonJumpBackward": "Posun späť",
|
||||
"ButtonJumpForward": "Posun vpred",
|
||||
"ButtonLatest": "Najnovšie",
|
||||
"ButtonLibrary": "Knižnica",
|
||||
"ButtonLogout": "Odhlásenie",
|
||||
"ButtonLookup": "Vyhľadať",
|
||||
"ButtonManageTracks": "Spravovať stopy",
|
||||
"ButtonMapChapterTitles": "Mapovať názvy kapitol",
|
||||
"ButtonMatchAllAuthors": "Vyhľadať všetkých autorov",
|
||||
"ButtonMatchBooks": "Vyhľadať knihy",
|
||||
"ButtonNevermind": "Nevadí",
|
||||
"ButtonNext": "Ďalšie",
|
||||
"ButtonNextChapter": "Ďalšia kapitola",
|
||||
"ButtonNextItemInQueue": "Ďalšia položka v poradí",
|
||||
"ButtonOk": "OK",
|
||||
"ButtonOpenFeed": "Otvoriť zdroj",
|
||||
"ButtonOpenManager": "Otvoriť správcu",
|
||||
"ButtonPause": "Zastaviť",
|
||||
"ButtonPlay": "Prehrať",
|
||||
"ButtonPlayAll": "Prehrať všetko",
|
||||
"ButtonPlaying": "Prehráva sa",
|
||||
"ButtonPlaylists": "Playlisty",
|
||||
"ButtonPrevious": "Predchádzajúci",
|
||||
"ButtonPreviousChapter": "Predchádzajúca kapitola",
|
||||
"ButtonProbeAudioFile": "Preskúmaj zvukový súbor",
|
||||
"ButtonPurgeAllCache": "Vymaž celú medzipamäť",
|
||||
"ButtonPurgeItemsCache": "Vymaž medzipamäť položiek",
|
||||
"ButtonQueueAddItem": "Pridať do poradia",
|
||||
"ButtonQueueRemoveItem": "Vymazať z poradia",
|
||||
"ButtonQuickEmbed": "Rýchle vloženie",
|
||||
"ButtonQuickEmbedMetadata": "Rýchle vloženie metadát",
|
||||
"ButtonQuickMatch": "Rýchle vyhľadanie",
|
||||
"ButtonReScan": "Preskenovať",
|
||||
"ButtonRead": "Načítať",
|
||||
"ButtonReadLess": "Načítať menej",
|
||||
"ButtonReadMore": "Načítať viac",
|
||||
"ButtonRefresh": "Obnoviť",
|
||||
"ButtonRemove": "Odstrániť",
|
||||
"ButtonRemoveAll": "Odstrániť všetko",
|
||||
"ButtonRemoveAllLibraryItems": "Odstrániť všetky položky knižnice",
|
||||
"ButtonRemoveFromContinueListening": "Odstrániť z nedokončených podcastov",
|
||||
"ButtonRemoveFromContinueReading": "Odtrániť z nedokončených audiokníh",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Odstrániť z nedokončených sérií",
|
||||
"ButtonReset": "Resetovať",
|
||||
"ButtonResetToDefault": "Resetovať do predvolené",
|
||||
"ButtonRestore": "Obnoviť zo zálohy",
|
||||
"ButtonSave": "Uložiť",
|
||||
"ButtonSaveAndClose": "Uložiť a zavrieť",
|
||||
"ButtonSaveTracklist": "Uložiť zoznam",
|
||||
"ButtonScan": "Skenovať",
|
||||
"ButtonScanLibrary": "Skenovať knižnicu",
|
||||
"HeaderMatch": "Spárovať",
|
||||
"LabelBackupsNumberToKeepHelp": "Týmto spôsobom odstránite vždy iba jednu zálohu. V prípade, ak chcete odtrániť viacero záloh, mali by ste ich odstrániť manuálne."
|
||||
}
|
@ -558,6 +558,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami",
|
||||
"LabelSettingsChromecastSupport": "Podpora za Chromecast",
|
||||
"LabelSettingsDateFormat": "Oblika datuma",
|
||||
"LabelSettingsEnableWatcher": "Samodejno preglej knjižnice za spremembe",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Samodejno preglej knjižnico za spremembe",
|
||||
"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.",
|
||||
|
@ -36,7 +36,7 @@
|
||||
"ButtonFullPath": "Fullständig sökväg",
|
||||
"ButtonHide": "Dölj",
|
||||
"ButtonHome": "Hem",
|
||||
"ButtonIssues": "Problem",
|
||||
"ButtonIssues": "Objekt med problem",
|
||||
"ButtonJumpBackward": "Hoppa bakåt",
|
||||
"ButtonJumpForward": "Hoppa framåt",
|
||||
"ButtonLatest": "Senaste",
|
||||
@ -71,7 +71,7 @@
|
||||
"ButtonQuickMatch": "Snabbmatchning",
|
||||
"ButtonReScan": "Ny skanning",
|
||||
"ButtonRead": "Läs",
|
||||
"ButtonReadLess": "Visa mindre",
|
||||
"ButtonReadLess": "Läs mindre",
|
||||
"ButtonReadMore": "Läs mer",
|
||||
"ButtonRefresh": "Uppdatera",
|
||||
"ButtonRemove": "Ta bort",
|
||||
@ -128,12 +128,12 @@
|
||||
"HeaderCollectionItems": "Böcker i samlingen",
|
||||
"HeaderCover": "Omslag",
|
||||
"HeaderCurrentDownloads": "Aktuella nedladdningar",
|
||||
"HeaderCustomMessageOnLogin": "Anpassat meddelande in inloggning",
|
||||
"HeaderCustomMessageOnLogin": "Meddelande att visa på sidan för inloggning",
|
||||
"HeaderCustomMetadataProviders": "Egen källa för metadata",
|
||||
"HeaderDetails": "Detaljer",
|
||||
"HeaderDownloadQueue": "Nedladdningskö",
|
||||
"HeaderEbookFiles": "E-boksfiler",
|
||||
"HeaderEmail": "E-postadress",
|
||||
"HeaderEmail": "E-post",
|
||||
"HeaderEmailSettings": "Inställningar för e-post",
|
||||
"HeaderEpisodes": "Avsnitt",
|
||||
"HeaderEreaderDevices": "Enheter för att läsa e-böcker",
|
||||
@ -301,11 +301,13 @@
|
||||
"LabelDownloadable": "Nedladdningsbar",
|
||||
"LabelDuration": "Varaktighet",
|
||||
"LabelDurationComparisonExactMatch": "(exakt matchning)",
|
||||
"LabelDurationComparisonLonger": "({0} längre)",
|
||||
"LabelDurationComparisonShorter": "({0} kortare)",
|
||||
"LabelDurationFound": "Varaktighet hittad:",
|
||||
"LabelEbook": "E-bok",
|
||||
"LabelEbooks": "E-böcker",
|
||||
"LabelEdit": "Redigera",
|
||||
"LabelEmail": "E-postadress",
|
||||
"LabelEmail": "E-post",
|
||||
"LabelEmailSettingsFromAddress": "Från e-postadress",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Avvisa icke-autentiserade certifikat",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Inaktivering av SSL-certifikatsvalidering kan exponera din anslutning för säkerhetsrisker, såsom man-in-the-middle-attacker. Inaktivera bara denna inställning om du förstår implikationerna och litar på den epostserver du ansluter till.",
|
||||
@ -414,6 +416,7 @@
|
||||
"LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum",
|
||||
"LabelLowestPriority": "Lägst prioritet",
|
||||
"LabelMatchExistingUsersBy": "Matcha befintliga användare med",
|
||||
"LabelMatchExistingUsersByDescription": "Används för att koppla existerande användare. När kopplingen sker kommer användaren att matchas med ett unikt ID från SSO-leverantören.",
|
||||
"LabelMaxEpisodesToDownload": "Maximalt antal avsnitt att ladda ner (0 = obegränsat).",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Maximalt antal nya avsnitt att ladda ner per tillfälle",
|
||||
"LabelMaxEpisodesToKeep": "Maximalt antal avsnitt att behålla",
|
||||
@ -524,7 +527,7 @@
|
||||
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
|
||||
"LabelSelectUsers": "Välj användare",
|
||||
"LabelSendEbookToDevice": "Skicka e-bok till...",
|
||||
"LabelSequence": "Sekvensnummer",
|
||||
"LabelSequence": "Ordningsnummer",
|
||||
"LabelSerial": "Seriell",
|
||||
"LabelSeries": "Serier",
|
||||
"LabelSeriesName": "Serienamn",
|
||||
@ -679,7 +682,7 @@
|
||||
"MessageAddToPlayerQueue": "Lägg till i spellistan",
|
||||
"MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt,<br>serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>.<br>De inkluderar <strong>INTE</strong> några filer lagrade i dina biblioteksmappar.",
|
||||
"MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit.",
|
||||
"MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit",
|
||||
"MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.",
|
||||
"MessageBackupsLocationPathEmpty": "Uppgiften om platsen för lagring av säkerhetskopior kan inte lämnas tom",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Adderar information från alla objekt nedan i de fält som aktiverats. Om fälten innehåller olika uppgifter kommer informationen att slås samman.",
|
||||
@ -777,7 +780,7 @@
|
||||
"MessageNoEpisodes": "Inga avsnitt",
|
||||
"MessageNoFoldersAvailable": "Inga mappar tillgängliga",
|
||||
"MessageNoGenres": "Inga kategorier",
|
||||
"MessageNoIssues": "Inga problem",
|
||||
"MessageNoIssues": "Inga objekt med problem hittades",
|
||||
"MessageNoItems": "Inga objekt",
|
||||
"MessageNoItemsFound": "Inga objekt hittades",
|
||||
"MessageNoListeningSessions": "Inga lyssningstillfällen",
|
||||
@ -848,6 +851,7 @@
|
||||
"MessageTaskScanItemsMissing": "{0} saknades",
|
||||
"MessageTaskScanItemsUpdated": "{0} uppdaterades",
|
||||
"MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades",
|
||||
"MessageTaskScanningFileChanges": "Söker efter ändrade filer i \"{0}\"",
|
||||
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
|
||||
"MessageTaskTargetDirectoryNotWritable": "Det är inte tillåtet att skriva i den angivna katalogen",
|
||||
"MessageThinking": "Tänker...",
|
||||
|
@ -558,6 +558,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць",
|
||||
"LabelSettingsChromecastSupport": "Підтримка Chromecast",
|
||||
"LabelSettingsDateFormat": "Формат дати",
|
||||
"LabelSettingsEnableWatcher": "Автоматично сканувати бібліотеки на наявність змін",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Автоматично сканувати бібліотеку на наявність змін",
|
||||
"LabelSettingsEnableWatcherHelp": "Вмикає автоматичне додавання/оновлення елементів, коли спостерігаються зміни файлів. *Потребує перезавантаження сервера",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Дозволити JavaScript-вміст у epub",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Дозволяти epub-файлам виконувати код. Вмикайте цей параметр лише якщо ви довіряєте джерелу epub-файлів.",
|
||||
@ -575,7 +577,7 @@
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропускати попередні книги у Продовжити серії",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Полиця Продовжити серії на головній сторінці показує найпершу непочату книгу з тих серій, у яких ви завершили хоча б одну книгу та не маєте книг у процесі. Якщо увімкнути це налаштування, то серії продовжуватимуться з останньої завершеної книги, а не з першої непочатої.",
|
||||
"LabelSettingsParseSubtitles": "Дістати підзаголовки",
|
||||
"LabelSettingsParseSubtitlesHelp": "Дістати підзаголовки з назв тек аудіокниг.<br>Підзаголовок мусить йти після \" - \"<br>Наприклад, \"Назва книги - Це підзаголовок\" має підзаголовок \"Це підзаголовок\"",
|
||||
"LabelSettingsParseSubtitlesHelp": "Витягти субтитри з імен папок аудіокниг.<br>Підзаголовки мають бути розділені символом \" - \"<br>тобто. «Назва книги – тут підзаголовок» має підзаголовок «Тут підзаголовок»",
|
||||
"LabelSettingsPreferMatchedMetadata": "Надавати перевагу віднайденим метаданим",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Подробиці буде перезаписано віднайденими даними Швидкого пошуку. Без цього Швидкий пошук заповнить лише подробиці, яких бракує.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Не шукати книги, що мають ASIN",
|
||||
|
@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "管理员",
|
||||
"LabelAccountTypeGuest": "来宾",
|
||||
"LabelAccountTypeUser": "用户",
|
||||
"LabelActivities": "活动",
|
||||
"LabelActivity": "活动",
|
||||
"LabelAddToCollection": "添加到收藏",
|
||||
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
|
||||
@ -251,7 +252,7 @@
|
||||
"LabelBackToUser": "返回到用户",
|
||||
"LabelBackupAudioFiles": "备份音频文件",
|
||||
"LabelBackupLocation": "备份位置",
|
||||
"LabelBackupsEnableAutomaticBackups": "启用自动备份",
|
||||
"LabelBackupsEnableAutomaticBackups": "自动备份",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "备份保存到 /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "最大备份大小 (GB) (0 为无限制)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.",
|
||||
@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "继续收听系列",
|
||||
"LabelCover": "封面",
|
||||
"LabelCoverImageURL": "封面图像 URL",
|
||||
"LabelCoverProvider": "封面提供者",
|
||||
"LabelCreatedAt": "创建时间",
|
||||
"LabelCronExpression": "计划任务表达式",
|
||||
"LabelCurrent": "当前",
|
||||
@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "每 6 小时",
|
||||
"LabelIntervalEveryDay": "每天",
|
||||
"LabelIntervalEveryHour": "每小时",
|
||||
"LabelIntervalEveryMinute": "每分钟",
|
||||
"LabelInvert": "倒转",
|
||||
"LabelItem": "项目",
|
||||
"LabelJumpBackwardAmount": "向后跳转时间",
|
||||
@ -555,6 +558,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
|
||||
"LabelSettingsChromecastSupport": "Chromecast 支持",
|
||||
"LabelSettingsDateFormat": "日期格式",
|
||||
"LabelSettingsEnableWatcher": "自动扫描库以查找更改",
|
||||
"LabelSettingsEnableWatcherForLibrary": "自动扫描库以查找更改",
|
||||
"LabelSettingsEnableWatcherHelp": "当检测到文件更改时, 启用项目的自动添加/更新. *需要重新启动服务器",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "允许 epubs 中包含脚本内容",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "允许 epub 文件执行脚本. 建议将此设置保持禁用, 除非你信任 epub 文件的来源.",
|
||||
@ -840,6 +845,7 @@
|
||||
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
|
||||
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
|
||||
"MessageScheduleLibraryScanNote": "对于大多数用户, 建议禁用此功能并保持文件夹监视程序设置启用. 文件夹监视程序将自动检测库文件夹中的更改. 文件夹监视程序不适用于每个文件系统 (如 NFS), 因此可以使用计划库扫描.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "每隔 {0} 在 {1} 运行一次",
|
||||
"MessageSearchResultsFor": "搜索结果",
|
||||
"MessageSelected": "{0} 已选择",
|
||||
"MessageServerCouldNotBeReached": "无法访问服务器",
|
||||
|
540
package-lock.json
generated
540
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.5",
|
||||
"version": "2.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.5",
|
||||
"version": "2.20.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
@ -25,7 +25,7 @@
|
||||
"semver": "^7.6.3",
|
||||
"sequelize": "^6.35.2",
|
||||
"socket.io": "^4.5.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ssrf-req-filter": "^1.1.0",
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
@ -587,39 +587,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz",
|
||||
"integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"make-dir": "^3.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"nopt": "^5.0.0",
|
||||
"npmlog": "^5.0.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.5",
|
||||
"tar": "^6.1.11"
|
||||
},
|
||||
"bin": {
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
|
||||
"dependencies": {
|
||||
"abbrev": "1"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/fs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
|
||||
@ -741,7 +708,8 @@
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
@ -759,6 +727,7 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
@ -770,6 +739,7 @@
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
@ -785,7 +755,8 @@
|
||||
"node_modules/agent-base/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=="
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/agentkeepalive": {
|
||||
"version": "4.3.0",
|
||||
@ -850,6 +821,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -897,7 +869,8 @@
|
||||
"node_modules/aproba": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
|
||||
"integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ=="
|
||||
"integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/archy": {
|
||||
"version": "1.0.0",
|
||||
@ -905,18 +878,6 @@
|
||||
"integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/are-we-there-yet": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
|
||||
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
|
||||
"dependencies": {
|
||||
"delegates": "^1.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
@ -957,7 +918,28 @@
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
@ -976,6 +958,26 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
|
||||
@ -1003,6 +1005,7 @@
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@ -1058,6 +1061,30 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@ -1326,6 +1353,7 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
@ -1350,12 +1378,14 @@
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="
|
||||
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
@ -1461,6 +1491,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
|
||||
@ -1473,6 +1518,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-require-extensions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
|
||||
@ -1499,7 +1553,8 @@
|
||||
"node_modules/delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="
|
||||
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
@ -1519,9 +1574,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz",
|
||||
"integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
|
||||
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -1613,7 +1669,8 @@
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
@ -1644,6 +1701,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.5.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz",
|
||||
@ -1777,6 +1843,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.18.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
|
||||
@ -1844,6 +1919,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
@ -1993,6 +2074,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
@ -2007,7 +2094,8 @@
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
@ -2017,25 +2105,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
|
||||
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
|
||||
"dependencies": {
|
||||
"aproba": "^1.0.3 || ^2.0.0",
|
||||
"color-support": "^1.1.2",
|
||||
"console-control-strings": "^1.0.0",
|
||||
"has-unicode": "^2.0.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"signal-exit": "^3.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wide-align": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@ -2085,10 +2154,17 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
@ -2164,7 +2240,8 @@
|
||||
"node_modules/has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="
|
||||
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/hasha": {
|
||||
"version": "5.2.2",
|
||||
@ -2277,6 +2354,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
@ -2289,6 +2367,7 @@
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
@ -2304,7 +2383,8 @@
|
||||
"node_modules/https-proxy-agent/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=="
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
@ -2326,6 +2406,26 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
@ -2368,6 +2468,7 @@
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
@ -2378,6 +2479,12 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
|
||||
@ -2417,6 +2524,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -2885,6 +2993,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==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"semver": "^6.0.0"
|
||||
},
|
||||
@ -2899,6 +3008,7 @@
|
||||
"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"
|
||||
}
|
||||
@ -2993,10 +3103,23 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@ -3004,6 +3127,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
@ -3103,6 +3235,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mocha": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz",
|
||||
@ -3399,6 +3537,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@ -3438,30 +3582,24 @@
|
||||
"isarray": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
|
||||
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz",
|
||||
"integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==",
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.74.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz",
|
||||
"integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz",
|
||||
@ -3658,17 +3796,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npmlog": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
||||
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
|
||||
"dependencies": {
|
||||
"are-we-there-yet": "^2.0.0",
|
||||
"console-control-strings": "^1.1.0",
|
||||
"gauge": "^3.0.0",
|
||||
"set-blocking": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nyc": {
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz",
|
||||
@ -3962,6 +4089,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -4029,6 +4157,32 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/process-on-spawn": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz",
|
||||
@ -4078,6 +4232,16 @@
|
||||
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
|
||||
"integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
|
||||
@ -4131,6 +4295,30 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/rc/node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@ -4210,6 +4398,7 @@
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@ -4404,7 +4593,8 @@
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
@ -4448,7 +4638,53 @@
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "1.1.0",
|
||||
@ -4701,13 +4937,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sqlite3": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz",
|
||||
"integrity": "sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw==",
|
||||
"version": "5.1.7",
|
||||
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
|
||||
"integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "^1.0.0",
|
||||
"node-addon-api": "^4.2.0",
|
||||
"bindings": "^1.5.0",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"prebuild-install": "^7.1.1",
|
||||
"tar": "^6.1.11"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@ -4770,6 +5008,7 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
@ -4783,6 +5022,7 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
@ -4839,6 +5079,40 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
|
||||
"integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tar/node_modules/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
@ -4907,10 +5181,17 @@
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-detect": {
|
||||
"version": "4.0.8",
|
||||
@ -5061,20 +5342,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@ -5100,6 +5367,7 @@
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.5",
|
||||
"version": "2.20.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
@ -52,7 +52,7 @@
|
||||
"semver": "^7.6.3",
|
||||
"sequelize": "^6.35.2",
|
||||
"socket.io": "^4.5.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ssrf-req-filter": "^1.1.0",
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
|
@ -782,6 +782,7 @@ class Database {
|
||||
await this.addTriggerIfNotExists('books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
|
||||
await this.addTriggerIfNotExists('podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')
|
||||
await this.addTriggerIfNotExists('podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
|
||||
await this.addAuthorNamesTriggersIfNotExist()
|
||||
}
|
||||
|
||||
async addTriggerIfNotExists(sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
|
||||
@ -806,6 +807,74 @@ class Database {
|
||||
`)
|
||||
}
|
||||
|
||||
async addAuthorNamesTriggersIfNotExist() {
|
||||
const libraryItems = 'libraryItems'
|
||||
const bookAuthors = 'bookAuthors'
|
||||
const authors = 'authors'
|
||||
const columns = [
|
||||
{ name: 'authorNamesFirstLast', source: `${authors}.name`, spec: { type: Sequelize.STRING, allowNull: true } },
|
||||
{ name: 'authorNamesLastFirst', source: `${authors}.lastFirst`, spec: { type: Sequelize.STRING, allowNull: true } }
|
||||
]
|
||||
const authorsSort = `${bookAuthors}.createdAt ASC`
|
||||
const columnNames = columns.map((column) => column.name).join(', ')
|
||||
const columnSourcesExpression = columns.map((column) => `GROUP_CONCAT(${column.source}, ', ' ORDER BY ${authorsSort})`).join(', ')
|
||||
const authorsJoin = `${authors} JOIN ${bookAuthors} ON ${authors}.id = ${bookAuthors}.authorId`
|
||||
|
||||
const addBookAuthorsTriggerIfNotExists = async (action) => {
|
||||
const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW'
|
||||
const triggerName = this.convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`)
|
||||
const authorNamesSubQuery = `
|
||||
SELECT ${columnSourcesExpression}
|
||||
FROM ${authorsJoin}
|
||||
WHERE ${bookAuthors}.bookId = ${modifiedRecord}.bookId
|
||||
`
|
||||
const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`)
|
||||
if (count > 0) return // Trigger already exists
|
||||
|
||||
Logger.info(`[Database] Adding trigger ${triggerName}`)
|
||||
|
||||
await this.sequelize.query(`
|
||||
CREATE TRIGGER ${triggerName}
|
||||
AFTER ${action} ON ${bookAuthors}
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE ${libraryItems}
|
||||
SET (${columnNames}) = (${authorNamesSubQuery})
|
||||
WHERE mediaId = ${modifiedRecord}.bookId;
|
||||
END;
|
||||
`)
|
||||
}
|
||||
|
||||
const addAuthorsUpdateTriggerIfNotExists = async () => {
|
||||
const triggerName = this.convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`)
|
||||
const authorNamesSubQuery = `
|
||||
SELECT ${columnSourcesExpression}
|
||||
FROM ${authorsJoin}
|
||||
WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId
|
||||
`
|
||||
|
||||
const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`)
|
||||
if (count > 0) return // Trigger already exists
|
||||
|
||||
Logger.info(`[Database] Adding trigger ${triggerName}`)
|
||||
|
||||
await this.sequelize.query(`
|
||||
CREATE TRIGGER ${triggerName}
|
||||
AFTER UPDATE OF name ON ${authors}
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE ${libraryItems}
|
||||
SET (${columnNames}) = (${authorNamesSubQuery})
|
||||
WHERE mediaId IN (SELECT bookId FROM ${bookAuthors} WHERE authorId = NEW.id);
|
||||
END;
|
||||
`)
|
||||
}
|
||||
|
||||
await addBookAuthorsTriggerIfNotExists('insert')
|
||||
await addBookAuthorsTriggerIfNotExists('delete')
|
||||
await addAuthorsUpdateTriggerIfNotExists()
|
||||
}
|
||||
|
||||
convertToSnakeCase(str) {
|
||||
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||
const authorFilters = require('../utils/queries/authorFilters')
|
||||
const zipHelpers = require('../utils/zipHelpers')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
@ -528,7 +529,7 @@ class LibraryController {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
|
||||
// Remove library watcher
|
||||
Watcher.removeLibrary(req.library)
|
||||
|
||||
@ -1419,6 +1420,52 @@ class LibraryController {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/library/:id/download
|
||||
* Downloads multiple library items
|
||||
*
|
||||
* @param {LibraryControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async downloadMultiple(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.warn(`User "${req.user.username}" attempted to download without permission`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (!req.query.ids || typeof req.query.ids !== 'string') {
|
||||
res.status(400).send('Invalid request. ids must be a string')
|
||||
return
|
||||
}
|
||||
|
||||
const itemIds = req.query.ids.split(',')
|
||||
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'libraryId', 'path', 'isFile'],
|
||||
where: {
|
||||
id: itemIds
|
||||
}
|
||||
})
|
||||
|
||||
Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`)
|
||||
|
||||
const filename = `LibraryItems-${Date.now()}.zip`
|
||||
const pathObjects = libraryItems.map((li) => ({ path: li.path, isFile: li.isFile }))
|
||||
|
||||
if (!pathObjects.length) {
|
||||
Logger.warn(`[LibraryController] No library items found for ids "${itemIds}"`)
|
||||
return res.status(404).send('Library items not found')
|
||||
}
|
||||
|
||||
try {
|
||||
await zipHelpers.zipDirectoriesPipe(pathObjects, filename, res)
|
||||
Logger.info(`[LibraryController] Downloaded ${pathObjects.length} items "${filename}"`)
|
||||
} catch (error) {
|
||||
Logger.error(`[LibraryController] Download failed for items "${filename}" at ${pathObjects.map((po) => po.path).join(', ')}`, error)
|
||||
zipHelpers.handleDownloadError(error, res)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
|
@ -108,7 +108,7 @@ class PodcastManager {
|
||||
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
|
||||
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
|
||||
if (await fs.pathExists(this.currentDownload.targetPath)) {
|
||||
this.currentDownload.appendRandomId = true
|
||||
this.currentDownload.setAppendRandomId(true)
|
||||
}
|
||||
|
||||
// Ignores all added files to this dir
|
||||
|
@ -15,3 +15,4 @@ Please add a record of every database migration that you create to this file. Th
|
||||
| v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |
|
||||
| v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices |
|
||||
| v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems |
|
||||
| v2.20.0 | v2.20.0-improve-author-sort-queries | Adds AuthorNames(FirstLast\|LastFirst) to libraryItems to improve author sort queries |
|
||||
|
272
server/migrations/v2.20.0-improve-author-sort-queries.js
Normal file
272
server/migrations/v2.20.0-improve-author-sort-queries.js
Normal file
@ -0,0 +1,272 @@
|
||||
const util = require('util')
|
||||
const { Sequelize } = 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.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.20.0'
|
||||
const migrationName = `${migrationVersion}-improve-author-sort-queries`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
// Migration constants
|
||||
const libraryItems = 'libraryItems'
|
||||
const bookAuthors = 'bookAuthors'
|
||||
const authors = 'authors'
|
||||
const podcastEpisodes = 'podcastEpisodes'
|
||||
const columns = [
|
||||
{ name: 'authorNamesFirstLast', source: `${authors}.name`, spec: { type: Sequelize.STRING, allowNull: true } },
|
||||
{ name: 'authorNamesLastFirst', source: `${authors}.lastFirst`, spec: { type: Sequelize.STRING, allowNull: true } }
|
||||
]
|
||||
const authorsSort = `${bookAuthors}.createdAt ASC`
|
||||
const columnNames = columns.map((column) => column.name).join(', ')
|
||||
const columnSourcesExpression = columns.map((column) => `GROUP_CONCAT(${column.source}, ', ' ORDER BY ${authorsSort})`).join(', ')
|
||||
const authorsJoin = `${authors} JOIN ${bookAuthors} ON ${authors}.id = ${bookAuthors}.authorId`
|
||||
|
||||
/**
|
||||
* This upward migration adds an authorNames column to the libraryItems table and populates it.
|
||||
* It also creates triggers to update the authorNames column when the corresponding bookAuthors and authors records are updated.
|
||||
* It also creates an index on the authorNames column.
|
||||
*
|
||||
* It also adds an index on publishedAt to the podcastEpisodes table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
const helper = new MigrationHelper(queryInterface, logger)
|
||||
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Add authorNames columns to libraryItems table
|
||||
await helper.addColumns()
|
||||
|
||||
// Populate authorNames columns with the author names for each libraryItem
|
||||
await helper.populateColumnsFromSource()
|
||||
|
||||
// Create triggers to update the authorNames column when the corresponding bookAuthors and authors records are updated
|
||||
await helper.addTriggers()
|
||||
|
||||
// Create indexes on the authorNames columns
|
||||
await helper.addIndexes()
|
||||
|
||||
// Add index on publishedAt to the podcastEpisodes table
|
||||
await helper.addIndex(podcastEpisodes, ['publishedAt'])
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration removes the authorNames column from the libraryItems table,
|
||||
* the triggers on the bookAuthors and authors tables, and the index on the authorNames column.
|
||||
*
|
||||
* It also removes the index on publishedAt from the podcastEpisodes table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
const helper = new MigrationHelper(queryInterface, logger)
|
||||
|
||||
// Remove triggers to update authorNames columns
|
||||
await helper.removeTriggers()
|
||||
|
||||
// Remove index on publishedAt from the podcastEpisodes table
|
||||
await helper.removeIndex(podcastEpisodes, ['publishedAt'])
|
||||
|
||||
// Remove indexes on the authorNames columns
|
||||
await helper.removeIndexes()
|
||||
|
||||
// Remove authorNames columns from libraryItems table
|
||||
await helper.removeColumns()
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
class MigrationHelper {
|
||||
constructor(queryInterface, logger) {
|
||||
this.queryInterface = queryInterface
|
||||
this.logger = logger
|
||||
}
|
||||
|
||||
async addColumn(table, column, options) {
|
||||
this.logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`)
|
||||
const tableDescription = await this.queryInterface.describeTable(table)
|
||||
if (!tableDescription[column]) {
|
||||
await this.queryInterface.addColumn(table, column, options)
|
||||
this.logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`)
|
||||
} else {
|
||||
this.logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`)
|
||||
}
|
||||
}
|
||||
|
||||
async addColumns() {
|
||||
this.logger.info(`${loggerPrefix} adding ${columnNames} columns to ${libraryItems} table`)
|
||||
for (const column of columns) {
|
||||
await this.addColumn(libraryItems, column.name, column.spec)
|
||||
}
|
||||
this.logger.info(`${loggerPrefix} added ${columnNames} columns to ${libraryItems} table`)
|
||||
}
|
||||
|
||||
async removeColumn(table, column) {
|
||||
this.logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`)
|
||||
const tableDescription = await this.queryInterface.describeTable(table)
|
||||
if (tableDescription[column]) {
|
||||
await this.queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`)
|
||||
this.logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`)
|
||||
} else {
|
||||
this.logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`)
|
||||
}
|
||||
}
|
||||
|
||||
async removeColumns() {
|
||||
this.logger.info(`${loggerPrefix} removing ${columnNames} columns from ${libraryItems} table`)
|
||||
for (const column of columns) {
|
||||
await this.removeColumn(libraryItems, column.name)
|
||||
}
|
||||
this.logger.info(`${loggerPrefix} removed ${columnNames} columns from ${libraryItems} table`)
|
||||
}
|
||||
|
||||
async populateColumnsFromSource() {
|
||||
this.logger.info(`${loggerPrefix} populating ${columnNames} columns in ${libraryItems} table`)
|
||||
const authorNamesSubQuery = `
|
||||
SELECT ${columnSourcesExpression}
|
||||
FROM ${authorsJoin}
|
||||
WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId
|
||||
`
|
||||
await this.queryInterface.sequelize.query(`
|
||||
UPDATE ${libraryItems}
|
||||
SET (${columnNames}) = (${authorNamesSubQuery})
|
||||
WHERE mediaType = 'book';
|
||||
`)
|
||||
this.logger.info(`${loggerPrefix} populated ${columnNames} columns in ${libraryItems} table`)
|
||||
}
|
||||
|
||||
async addBookAuthorsTrigger(action) {
|
||||
this.logger.info(`${loggerPrefix} adding trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`)
|
||||
const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW'
|
||||
const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`)
|
||||
const authorNamesSubQuery = `
|
||||
SELECT ${columnSourcesExpression}
|
||||
FROM ${authorsJoin}
|
||||
WHERE ${bookAuthors}.bookId = ${modifiedRecord}.bookId
|
||||
`
|
||||
await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
|
||||
|
||||
await this.queryInterface.sequelize.query(`
|
||||
CREATE TRIGGER ${triggerName}
|
||||
AFTER ${action} ON ${bookAuthors}
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE ${libraryItems}
|
||||
SET (${columnNames}) = (${authorNamesSubQuery})
|
||||
WHERE mediaId = ${modifiedRecord}.bookId;
|
||||
END;
|
||||
`)
|
||||
this.logger.info(`${loggerPrefix} added trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`)
|
||||
}
|
||||
|
||||
async addAuthorsUpdateTrigger() {
|
||||
this.logger.info(`${loggerPrefix} adding trigger to update ${libraryItems} ${columnNames} on ${authors} update`)
|
||||
const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`)
|
||||
const authorNamesSubQuery = `
|
||||
SELECT ${columnSourcesExpression}
|
||||
FROM ${authorsJoin}
|
||||
WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId
|
||||
`
|
||||
|
||||
await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
|
||||
|
||||
await this.queryInterface.sequelize.query(`
|
||||
CREATE TRIGGER ${triggerName}
|
||||
AFTER UPDATE OF name ON ${authors}
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE ${libraryItems}
|
||||
SET (${columnNames}) = (${authorNamesSubQuery})
|
||||
WHERE mediaId IN (SELECT bookId FROM ${bookAuthors} WHERE authorId = NEW.id);
|
||||
END;
|
||||
`)
|
||||
this.logger.info(`${loggerPrefix} added trigger to update ${libraryItems} ${columnNames} on ${authors} update`)
|
||||
}
|
||||
|
||||
async addTriggers() {
|
||||
await this.addBookAuthorsTrigger('insert')
|
||||
await this.addBookAuthorsTrigger('delete')
|
||||
await this.addAuthorsUpdateTrigger()
|
||||
}
|
||||
|
||||
async removeBookAuthorsTrigger(action) {
|
||||
this.logger.info(`${loggerPrefix} removing trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`)
|
||||
const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`)
|
||||
await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
|
||||
this.logger.info(`${loggerPrefix} removed trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`)
|
||||
}
|
||||
|
||||
async removeAuthorsUpdateTrigger() {
|
||||
this.logger.info(`${loggerPrefix} removing trigger to update ${libraryItems} ${columnNames} on ${authors} update`)
|
||||
const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`)
|
||||
await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
|
||||
this.logger.info(`${loggerPrefix} removed trigger to update ${libraryItems} ${columnNames} on ${authors} update`)
|
||||
}
|
||||
|
||||
async removeTriggers() {
|
||||
await this.removeBookAuthorsTrigger('insert')
|
||||
await this.removeBookAuthorsTrigger('delete')
|
||||
await this.removeAuthorsUpdateTrigger()
|
||||
}
|
||||
|
||||
async addIndex(tableName, columns) {
|
||||
const columnString = columns.map((column) => util.inspect(column)).join(', ')
|
||||
const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`)
|
||||
try {
|
||||
this.logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}"`)
|
||||
await this.queryInterface.addIndex(tableName, columns)
|
||||
this.logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}"`)
|
||||
} catch (error) {
|
||||
if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
|
||||
this.logger.info(`${loggerPrefix} index [${columnString}] for table "${tableName}" already exists`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async addIndexes() {
|
||||
for (const column of columns) {
|
||||
await this.addIndex(libraryItems, ['libraryId', 'mediaType', { name: column.name, collate: 'NOCASE' }])
|
||||
}
|
||||
}
|
||||
|
||||
async removeIndex(tableName, columns) {
|
||||
this.logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`)
|
||||
await this.queryInterface.removeIndex(tableName, columns)
|
||||
this.logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`)
|
||||
}
|
||||
|
||||
async removeIndexes() {
|
||||
for (const column of columns) {
|
||||
await this.removeIndex(libraryItems, ['libraryId', 'mediaType', column.name])
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix"
|
||||
*
|
||||
* @param {string} str - the string to convert to snake case.
|
||||
* @returns {string} - the string in snake case.
|
||||
*/
|
||||
function convertToSnakeCase(str) {
|
||||
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
@ -374,6 +374,10 @@ class Book extends Model {
|
||||
if (payload.metadata) {
|
||||
const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language']
|
||||
metadataStringKeys.forEach((key) => {
|
||||
if (typeof payload.metadata[key] == 'number') {
|
||||
payload.metadata[key] = String(payload.metadata[key])
|
||||
}
|
||||
|
||||
if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && this[key] !== payload.metadata[key]) {
|
||||
this[key] = payload.metadata[key] || null
|
||||
|
||||
|
@ -77,6 +77,10 @@ class LibraryItem extends Model {
|
||||
this.title // Only used for sorting
|
||||
/** @type {string} */
|
||||
this.titleIgnorePrefix // Only used for sorting
|
||||
/** @type {string} */
|
||||
this.authorNamesFirstLast // Only used for sorting
|
||||
/** @type {string} */
|
||||
this.authorNamesLastFirst // Only used for sorting
|
||||
}
|
||||
|
||||
/**
|
||||
@ -683,7 +687,9 @@ class LibraryItem extends Model {
|
||||
libraryFiles: DataTypes.JSON,
|
||||
extraData: DataTypes.JSON,
|
||||
title: DataTypes.STRING,
|
||||
titleIgnorePrefix: DataTypes.STRING
|
||||
titleIgnorePrefix: DataTypes.STRING,
|
||||
authorNamesFirstLast: DataTypes.STRING,
|
||||
authorNamesLastFirst: DataTypes.STRING
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
@ -710,6 +716,12 @@ class LibraryItem extends Model {
|
||||
{
|
||||
fields: ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }]
|
||||
},
|
||||
{
|
||||
fields: ['libraryId', 'mediaType', { name: 'authorNamesFirstLast', collate: 'NOCASE' }]
|
||||
},
|
||||
{
|
||||
fields: ['libraryId', 'mediaType', { name: 'authorNamesLastFirst', collate: 'NOCASE' }]
|
||||
},
|
||||
{
|
||||
fields: ['libraryId', 'mediaId', 'mediaType']
|
||||
},
|
||||
|
@ -122,6 +122,10 @@ class PodcastEpisode extends Model {
|
||||
{
|
||||
name: 'podcastEpisode_createdAt_podcastId',
|
||||
fields: ['createdAt', 'podcastId']
|
||||
},
|
||||
{
|
||||
name: 'podcast_episodes_published_at',
|
||||
fields: ['publishedAt']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -20,6 +20,8 @@ class PodcastEpisodeDownload {
|
||||
|
||||
this.appendRandomId = false
|
||||
|
||||
this.targetFilename = null
|
||||
|
||||
this.startedAt = null
|
||||
this.createdAt = null
|
||||
this.finishedAt = null
|
||||
@ -74,11 +76,6 @@ class PodcastEpisodeDownload {
|
||||
get episodeTitle() {
|
||||
return this.rssPodcastEpisode.title
|
||||
}
|
||||
get targetFilename() {
|
||||
const appendage = this.appendRandomId ? ` (${this.id})` : ''
|
||||
const filename = `${this.rssPodcastEpisode.title}${appendage}.${this.fileExtension}`
|
||||
return sanitizeFilename(filename)
|
||||
}
|
||||
get targetPath() {
|
||||
return filePathToPOSIX(Path.join(this.libraryItem.path, this.targetFilename))
|
||||
}
|
||||
@ -93,6 +90,23 @@ class PodcastEpisodeDownload {
|
||||
return new Date(this.rssPodcastEpisode.publishedAt).getFullYear()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
*/
|
||||
getSanitizedFilename(title) {
|
||||
const appendage = this.appendRandomId ? ` (${this.id})` : ''
|
||||
const filename = `${title.trim()}${appendage}.${this.fileExtension}`
|
||||
return sanitizeFilename(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} appendRandomId
|
||||
*/
|
||||
setAppendRandomId(appendRandomId) {
|
||||
this.appendRandomId = appendRandomId
|
||||
this.targetFilename = this.getSanitizedFilename(this.rssPodcastEpisode.title || '')
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode - from rss feed
|
||||
@ -112,6 +126,8 @@ class PodcastEpisodeDownload {
|
||||
this.url = encodeURI(url)
|
||||
}
|
||||
|
||||
this.targetFilename = this.getSanitizedFilename(this.rssPodcastEpisode.title || '')
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
this.isAutoDownload = isAutoDownload
|
||||
this.createdAt = Date.now()
|
||||
|
@ -66,10 +66,10 @@ class OpenLibrary {
|
||||
}
|
||||
|
||||
parsePublishYear(doc, worksData) {
|
||||
if (doc.first_publish_year && !isNaN(doc.first_publish_year)) return doc.first_publish_year
|
||||
if (doc.first_publish_year && !isNaN(doc.first_publish_year)) return String(doc.first_publish_year)
|
||||
if (worksData.first_publish_date) {
|
||||
var year = worksData.first_publish_date.split('-')[0]
|
||||
if (!isNaN(year)) return year
|
||||
if (!isNaN(year)) return String(year)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
@ -94,6 +94,7 @@ class ApiRouter {
|
||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||
this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this))
|
||||
this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this))
|
||||
this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this))
|
||||
|
||||
//
|
||||
// Item Routes
|
||||
|
@ -523,6 +523,8 @@ class BookScanner {
|
||||
libraryItemObj.extraData = {}
|
||||
libraryItemObj.title = bookMetadata.title
|
||||
libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)
|
||||
libraryItemObj.authorNamesFirstLast = bookMetadata.authors.join(', ')
|
||||
libraryItemObj.authorNamesLastFirst = bookMetadata.authors.map((author) => Database.authorModel.getLastFirst(author)).join(', ')
|
||||
|
||||
// Set isSupplementary flag on ebook library files
|
||||
for (const libraryFile of libraryItemObj.libraryFiles) {
|
||||
|
@ -2,24 +2,26 @@ const { parseOpfMetadataXML } = require('../utils/parsers/parseOpfMetadata')
|
||||
const { readTextFile } = require('../utils/fileUtils')
|
||||
|
||||
class OpfFileScanner {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Parse metadata from .opf file found in library scan and update bookMetadata
|
||||
*
|
||||
* @param {import('../models/LibraryItem').LibraryFileObject} opfLibraryFileObj
|
||||
* @param {Object} bookMetadata
|
||||
*
|
||||
* @param {import('../models/LibraryItem').LibraryFileObject} opfLibraryFileObj
|
||||
* @param {Object} bookMetadata
|
||||
*/
|
||||
async scanBookOpfFile(opfLibraryFileObj, bookMetadata) {
|
||||
const xmlText = await readTextFile(opfLibraryFileObj.metadata.path)
|
||||
const opfMetadata = xmlText ? await parseOpfMetadataXML(xmlText) : null
|
||||
if (opfMetadata) {
|
||||
for (const key in opfMetadata) {
|
||||
if (key === 'tags') { // Add tags only if tags are empty
|
||||
if (key === 'tags') {
|
||||
// Add tags only if tags are empty
|
||||
if (opfMetadata.tags.length) {
|
||||
bookMetadata.tags = opfMetadata.tags
|
||||
}
|
||||
} else if (key === 'genres') { // Add genres only if genres are empty
|
||||
} else if (key === 'genres') {
|
||||
// Add genres only if genres are empty
|
||||
if (opfMetadata.genres.length) {
|
||||
bookMetadata.genres = opfMetadata.genres
|
||||
}
|
||||
@ -42,4 +44,4 @@ class OpfFileScanner {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new OpfFileScanner()
|
||||
module.exports = new OpfFileScanner()
|
||||
|
@ -362,6 +362,9 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize the string first to ensure consistent byte calculations
|
||||
filename = filename.normalize('NFC')
|
||||
|
||||
// Most file systems use number of bytes for max filename
|
||||
// to support most filesystems we will use max of 255 bytes in utf-16
|
||||
// Ref: https://doc.owncloud.com/server/next/admin_manual/troubleshooting/path_filename_length.html
|
||||
@ -390,8 +393,11 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
const ext = Path.extname(sanitized) // separate out file extension
|
||||
const basename = Path.basename(sanitized, ext)
|
||||
const extByteLength = Buffer.byteLength(ext, 'utf16le')
|
||||
|
||||
const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
|
||||
if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
|
||||
Logger.debug(`[fileUtils] Filename "${filename}" is too long (${basenameByteLength + extByteLength} bytes), trimming basename to ${MAX_FILENAME_BYTES - extByteLength} bytes.`)
|
||||
|
||||
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
|
||||
let totalBytes = 0
|
||||
let trimmedBasename = ''
|
||||
@ -407,6 +413,10 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
sanitized = trimmedBasename + ext
|
||||
}
|
||||
|
||||
if (filename !== sanitized) {
|
||||
Logger.debug(`[fileUtils] Sanitized filename "${filename}" to "${sanitized}" (${Buffer.byteLength(sanitized, 'utf16le')} bytes)`)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
|
@ -22,11 +22,22 @@ function parseCreators(metadata) {
|
||||
Object.keys(c['$'])
|
||||
.find((key) => key.startsWith('xmlns:'))
|
||||
?.split(':')[1] || 'opf'
|
||||
return {
|
||||
const creator = {
|
||||
value: c['_'],
|
||||
role: c['$'][`${namespace}:role`] || null,
|
||||
fileAs: c['$'][`${namespace}:file-as`] || null
|
||||
}
|
||||
|
||||
const id = c['$']['id']
|
||||
if (id && metadata.meta.refines?.some((r) => r.refines === `#${id}`)) {
|
||||
const creatorMeta = metadata.meta.refines.filter((r) => r.refines === `#${id}`)
|
||||
if (creatorMeta) {
|
||||
creator.role = creatorMeta.find((r) => r.property === 'role')?.value || creator.role || null
|
||||
creator.fileAs = creatorMeta.find((r) => r.property === 'file-as')?.value || creator.fileAs || null
|
||||
}
|
||||
}
|
||||
|
||||
return creator
|
||||
})
|
||||
}
|
||||
|
||||
@ -187,7 +198,6 @@ module.exports.parseOpfMetadataJson = (json) => {
|
||||
const prefix = packageKey.split(':').shift()
|
||||
let metadata = prefix ? json[packageKey][`${prefix}:metadata`] || json[packageKey].metadata : json[packageKey].metadata
|
||||
if (!metadata) return null
|
||||
|
||||
if (Array.isArray(metadata)) {
|
||||
if (!metadata.length) return null
|
||||
metadata = metadata[0]
|
||||
@ -198,12 +208,22 @@ module.exports.parseOpfMetadataJson = (json) => {
|
||||
metadata.meta = {}
|
||||
if (metadataMeta?.length) {
|
||||
metadataMeta.forEach((meta) => {
|
||||
if (meta && meta['$'] && meta['$'].name) {
|
||||
if (meta?.['$']?.name) {
|
||||
metadata.meta[meta['$'].name] = [meta['$'].content || '']
|
||||
} else if (meta?.['$']?.refines) {
|
||||
// https://www.w3.org/TR/epub-33/#sec-meta-elem
|
||||
|
||||
if (!metadata.meta.refines) {
|
||||
metadata.meta.refines = []
|
||||
}
|
||||
metadata.meta.refines.push({
|
||||
value: meta._,
|
||||
refines: meta['$'].refines,
|
||||
property: meta['$'].property
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const creators = parseCreators(metadata)
|
||||
const authors = (fetchCreators(creators, 'aut') || []).map((au) => au?.trim()).filter((au) => au)
|
||||
const narrators = (fetchNarrators(creators, metadata) || []).map((nrt) => nrt?.trim()).filter((nrt) => nrt)
|
||||
@ -227,5 +247,6 @@ module.exports.parseOpfMetadataJson = (json) => {
|
||||
module.exports.parseOpfMetadataXML = async (xml) => {
|
||||
const json = await xmlToJSON(xml)
|
||||
if (!json) return null
|
||||
|
||||
return this.parseOpfMetadataJson(json)
|
||||
}
|
||||
|
@ -264,9 +264,9 @@ module.exports = {
|
||||
} else if (sortBy === 'media.metadata.publishedYear') {
|
||||
return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]]
|
||||
} else if (sortBy === 'media.metadata.authorNameLF') {
|
||||
return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]]
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir]]
|
||||
} else if (sortBy === 'media.metadata.authorName') {
|
||||
return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]]
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir]]
|
||||
} else if (sortBy === 'media.metadata.title') {
|
||||
if (collapseseries) {
|
||||
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
|
||||
@ -397,18 +397,7 @@ module.exports = {
|
||||
const includeRSSFeed = include.includes('rssfeed')
|
||||
const includeMediaItemShare = !!user?.isAdminOrUp && include.includes('share')
|
||||
|
||||
// For sorting by author name an additional attribute must be added
|
||||
// with author names concatenated
|
||||
let bookAttributes = null
|
||||
if (sortBy === 'media.metadata.authorNameLF') {
|
||||
bookAttributes = {
|
||||
include: [[Sequelize.literal(`(SELECT group_concat(lastFirst, ", ") FROM (SELECT a.lastFirst FROM authors AS a, bookAuthors as ba WHERE ba.authorId = a.id AND ba.bookId = book.id ORDER BY ba.createdAt ASC))`), 'author_name']]
|
||||
}
|
||||
} else if (sortBy === 'media.metadata.authorName') {
|
||||
bookAttributes = {
|
||||
include: [[Sequelize.literal(`(SELECT group_concat(name, ", ") FROM (SELECT a.name FROM authors AS a, bookAuthors as ba WHERE ba.authorId = a.id AND ba.bookId = book.id ORDER BY ba.createdAt ASC))`), 'author_name']]
|
||||
}
|
||||
}
|
||||
|
||||
const libraryItemWhere = {
|
||||
libraryId
|
||||
|
@ -465,7 +465,7 @@ module.exports = {
|
||||
async getRecentEpisodes(user, library, limit, offset) {
|
||||
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
|
||||
|
||||
const episodes = await Database.podcastEpisodeModel.findAll({
|
||||
const findOptions = {
|
||||
where: {
|
||||
'$mediaProgresses.isFinished$': {
|
||||
[Sequelize.Op.or]: [null, false]
|
||||
@ -496,7 +496,11 @@ module.exports = {
|
||||
subQuery: false,
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
}
|
||||
|
||||
const findtAll = process.env.QUERY_PROFILING ? profile(Database.podcastEpisodeModel.findAll.bind(Database.podcastEpisodeModel)) : Database.podcastEpisodeModel.findAll.bind(Database.podcastEpisodeModel)
|
||||
|
||||
const episodes = await findtAll(findOptions)
|
||||
|
||||
const episodeResults = episodes.map((ep) => {
|
||||
ep.podcast.podcastEpisodes = [] // Not needed
|
||||
|
@ -1,3 +1,5 @@
|
||||
const Path = require('path')
|
||||
const { Response } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const archiver = require('../libs/archiver')
|
||||
|
||||
@ -50,3 +52,86 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
|
||||
archive.finalize()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a zip archive containing multiple directories and streams it to the response.
|
||||
*
|
||||
* @param {{ path: string, isFile: boolean }[]} pathObjects
|
||||
* @param {string} filename - Name of the zip file to be sent as attachment.
|
||||
* @param {Response} res - Response object to pipe the archive data to.
|
||||
* @returns {Promise<void>} - Promise that resolves when the zip operation completes.
|
||||
*/
|
||||
module.exports.zipDirectoriesPipe = (pathObjects, filename, res) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// create a file to stream archive data to
|
||||
res.attachment(filename)
|
||||
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 0 } // Sets the compression level.
|
||||
})
|
||||
|
||||
// listen for all archive data to be written
|
||||
// 'close' event is fired only when a file descriptor is involved
|
||||
res.on('close', () => {
|
||||
Logger.info(archive.pointer() + ' total bytes')
|
||||
Logger.debug('archiver has been finalized and the output file descriptor has closed.')
|
||||
resolve()
|
||||
})
|
||||
|
||||
// This event is fired when the data source is drained no matter what was the data source.
|
||||
// It is not part of this library but rather from the NodeJS Stream API.
|
||||
// @see: https://nodejs.org/api/stream.html#stream_event_end
|
||||
res.on('end', () => {
|
||||
Logger.debug('Data has been drained')
|
||||
})
|
||||
|
||||
// good practice to catch warnings (ie stat failures and other non-blocking errors)
|
||||
archive.on('warning', function (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// log warning
|
||||
Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)
|
||||
} else {
|
||||
// throw error
|
||||
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
|
||||
// throw err
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
archive.on('error', function (err) {
|
||||
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
// pipe archive data to the file
|
||||
archive.pipe(res)
|
||||
|
||||
// Add each path as a directory in the zip
|
||||
pathObjects.forEach((pathObject) => {
|
||||
if (!pathObject.isFile) {
|
||||
// Add the directory to the archive with its name as the root folder
|
||||
archive.directory(pathObject.path, Path.basename(pathObject.path))
|
||||
} else {
|
||||
archive.file(pathObject.path, { name: Path.basename(pathObject.path) })
|
||||
}
|
||||
})
|
||||
|
||||
archive.finalize()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors that occur during the download process.
|
||||
*
|
||||
* @param {*} error
|
||||
* @param {Response} res
|
||||
* @returns {*}
|
||||
*/
|
||||
module.exports.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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,361 @@
|
||||
const chai = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { expect } = chai
|
||||
|
||||
const { DataTypes, Sequelize } = require('sequelize')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
const { up, down } = require('../../../server/migrations/v2.20.0-improve-author-sort-queries')
|
||||
|
||||
const normalizeWhitespaceAndBackticks = (str) => str.replace(/\s+/g, ' ').trim().replace(/`/g, '')
|
||||
|
||||
describe('Migration v2.20.0-improve-author-sort-queries', () => {
|
||||
let sequelize
|
||||
let queryInterface
|
||||
let loggerInfoStub
|
||||
|
||||
beforeEach(async () => {
|
||||
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
queryInterface = sequelize.getQueryInterface()
|
||||
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||
|
||||
await queryInterface.createTable('libraryItems', {
|
||||
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||
mediaId: { type: DataTypes.INTEGER, allowNull: false },
|
||||
mediaType: { type: DataTypes.STRING, allowNull: false },
|
||||
libraryId: { type: DataTypes.INTEGER, allowNull: false }
|
||||
})
|
||||
|
||||
await queryInterface.createTable('authors', {
|
||||
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||
name: { type: DataTypes.STRING, allowNull: false },
|
||||
lastFirst: { type: DataTypes.STRING, allowNull: false }
|
||||
})
|
||||
|
||||
await queryInterface.createTable('bookAuthors', {
|
||||
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||
bookId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'libraryItems', key: 'id', onDelete: 'CASCADE' } },
|
||||
authorId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'authors', key: 'id', onDelete: 'CASCADE' } },
|
||||
createdAt: { type: DataTypes.DATE, allowNull: false }
|
||||
})
|
||||
|
||||
await queryInterface.createTable('podcastEpisodes', {
|
||||
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||
publishedAt: { type: DataTypes.DATE, allowNull: true }
|
||||
})
|
||||
|
||||
await queryInterface.bulkInsert('libraryItems', [
|
||||
{ id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 },
|
||||
{ id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 }
|
||||
])
|
||||
|
||||
await queryInterface.bulkInsert('authors', [
|
||||
{ id: 1, name: 'John Doe', lastFirst: 'Doe, John' },
|
||||
{ id: 2, name: 'Jane Smith', lastFirst: 'Smith, Jane' },
|
||||
{ id: 3, name: 'John Smith', lastFirst: 'Smith, John' }
|
||||
])
|
||||
|
||||
await queryInterface.bulkInsert('bookAuthors', [
|
||||
{ id: 1, bookId: 1, authorId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' },
|
||||
{ id: 2, bookId: 2, authorId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' },
|
||||
{ id: 3, bookId: 1, authorId: 3, createdAt: '2024-12-31 00:00:00.000 +00:00' }
|
||||
])
|
||||
|
||||
await queryInterface.bulkInsert('podcastEpisodes', [
|
||||
{ id: 1, publishedAt: '2025-01-01 00:00:00.000 +00:00' },
|
||||
{ id: 2, publishedAt: '2025-01-02 00:00:00.000 +00:00' },
|
||||
{ id: 3, publishedAt: '2025-01-03 00:00:00.000 +00:00' }
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('up', () => {
|
||||
it('should add the authorNamesFirstLast and authorNamesLastFirst columns to the libraryItems table', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const libraryItems = await queryInterface.describeTable('libraryItems')
|
||||
expect(libraryItems.authorNamesFirstLast).to.exist
|
||||
expect(libraryItems.authorNamesLastFirst).to.exist
|
||||
})
|
||||
|
||||
it('should populate the authorNamesFirstLast and authorNamesLastFirst columns with the author names for each libraryItem', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe', authorNamesLastFirst: 'Smith, John, Doe, John' },
|
||||
{ id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }
|
||||
])
|
||||
})
|
||||
|
||||
it('should create triggers to update the authorNamesFirstLast and authorNamesLastFirst columns when the corresponding bookAuthors and authors records are updated', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)
|
||||
expect(count).to.equal(1)
|
||||
|
||||
const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)
|
||||
expect(normalizeWhitespaceAndBackticks(sql)).to.equal(
|
||||
normalizeWhitespaceAndBackticks(`
|
||||
CREATE TRIGGER update_library_items_author_names_on_book_authors_insert
|
||||
AFTER insert ON bookAuthors
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE libraryItems
|
||||
SET (authorNamesFirstLast, authorNamesLastFirst) = (
|
||||
SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC)
|
||||
FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId
|
||||
WHERE bookAuthors.bookId = NEW.bookId
|
||||
)
|
||||
WHERE mediaId = NEW.bookId;
|
||||
END
|
||||
`)
|
||||
)
|
||||
|
||||
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)
|
||||
expect(count2).to.equal(1)
|
||||
|
||||
const [[{ sql: sql2 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)
|
||||
expect(normalizeWhitespaceAndBackticks(sql2)).to.equal(
|
||||
normalizeWhitespaceAndBackticks(`
|
||||
CREATE TRIGGER update_library_items_author_names_on_book_authors_delete
|
||||
AFTER delete ON bookAuthors
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE libraryItems
|
||||
SET (authorNamesFirstLast, authorNamesLastFirst) = (
|
||||
SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC)
|
||||
FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId
|
||||
WHERE bookAuthors.bookId = OLD.bookId
|
||||
)
|
||||
WHERE mediaId = OLD.bookId;
|
||||
END
|
||||
`)
|
||||
)
|
||||
|
||||
const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)
|
||||
expect(count3).to.equal(1)
|
||||
|
||||
const [[{ sql: sql3 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)
|
||||
expect(normalizeWhitespaceAndBackticks(sql3)).to.equal(
|
||||
normalizeWhitespaceAndBackticks(`
|
||||
CREATE TRIGGER update_library_items_author_names_on_authors_update
|
||||
AFTER UPDATE OF name ON authors
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE libraryItems
|
||||
SET (authorNamesFirstLast, authorNamesLastFirst) = (
|
||||
SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC)
|
||||
FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId
|
||||
WHERE bookAuthors.bookId = libraryItems.mediaId
|
||||
)
|
||||
WHERE mediaId IN (SELECT bookId FROM bookAuthors WHERE authorId = NEW.id);
|
||||
END
|
||||
`)
|
||||
)
|
||||
})
|
||||
|
||||
it('should create indexes on the authorNamesFirstLast and authorNamesLastFirst columns', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)
|
||||
expect(count).to.equal(1)
|
||||
|
||||
const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)
|
||||
expect(normalizeWhitespaceAndBackticks(sql)).to.equal(
|
||||
normalizeWhitespaceAndBackticks(`
|
||||
CREATE INDEX library_items_library_id_media_type_author_names_first_last ON libraryItems (libraryId, mediaType, authorNamesFirstLast COLLATE NOCASE)
|
||||
`)
|
||||
)
|
||||
|
||||
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)
|
||||
expect(count2).to.equal(1)
|
||||
|
||||
const [[{ sql: sql2 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)
|
||||
expect(normalizeWhitespaceAndBackticks(sql2)).to.equal(
|
||||
normalizeWhitespaceAndBackticks(`
|
||||
CREATE INDEX library_items_library_id_media_type_author_names_last_first ON libraryItems (libraryId, mediaType, authorNamesLastFirst COLLATE NOCASE)
|
||||
`)
|
||||
)
|
||||
})
|
||||
|
||||
it('should trigger after update on authors', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
// update author name
|
||||
await queryInterface.sequelize.query(`UPDATE authors SET (name, lastFirst) = ('John Wayne', 'Wayne, John') WHERE id = 1`)
|
||||
|
||||
// check that the libraryItems table was updated
|
||||
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Wayne', authorNamesLastFirst: 'Smith, John, Wayne, John' },
|
||||
{ id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }
|
||||
])
|
||||
})
|
||||
|
||||
it('should trigger after insert on bookAuthors', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
// insert a new author
|
||||
await queryInterface.sequelize.query(`INSERT INTO authors (id, name, lastFirst) VALUES (4, 'John Wayne', 'Wayne, John')`)
|
||||
|
||||
// insert a new bookAuthor
|
||||
await queryInterface.sequelize.query(`INSERT INTO bookAuthors (id, bookId, authorId, createdAt) VALUES (4, 1, 4, '2025-01-04 00:00:00.000 +00:00')`)
|
||||
|
||||
// check that the libraryItems table was updated
|
||||
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe, John Wayne', authorNamesLastFirst: 'Smith, John, Doe, John, Wayne, John' },
|
||||
{ id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }
|
||||
])
|
||||
})
|
||||
|
||||
it('should trigger after delete on bookAuthors', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
// delete a bookAuthor
|
||||
await queryInterface.sequelize.query(`DELETE FROM bookAuthors WHERE id = 1`)
|
||||
|
||||
// check that the libraryItems table was updated
|
||||
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith', authorNamesLastFirst: 'Smith, John' },
|
||||
{ id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }
|
||||
])
|
||||
})
|
||||
|
||||
it('should add an index on publishedAt to the podcastEpisodes table', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)
|
||||
expect(count).to.equal(1)
|
||||
|
||||
const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)
|
||||
expect(normalizeWhitespaceAndBackticks(sql)).to.equal(
|
||||
normalizeWhitespaceAndBackticks(`
|
||||
CREATE INDEX podcast_episodes_published_at ON podcastEpisodes (publishedAt)
|
||||
`)
|
||||
)
|
||||
})
|
||||
|
||||
it('should be idempotent', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const libraryItemsTable = await queryInterface.describeTable('libraryItems')
|
||||
expect(libraryItemsTable.authorNamesFirstLast).to.exist
|
||||
expect(libraryItemsTable.authorNamesLastFirst).to.exist
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)
|
||||
expect(count).to.equal(1)
|
||||
|
||||
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)
|
||||
expect(count2).to.equal(1)
|
||||
|
||||
const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)
|
||||
expect(count3).to.equal(1)
|
||||
|
||||
const [[{ count: count4 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)
|
||||
expect(count4).to.equal(1)
|
||||
|
||||
const [[{ count: count5 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)
|
||||
expect(count5).to.equal(1)
|
||||
|
||||
const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)
|
||||
expect(count6).to.equal(1)
|
||||
|
||||
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe', authorNamesLastFirst: 'Smith, John, Doe, John' },
|
||||
{ id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('down', () => {
|
||||
it('should remove the authorNamesFirstLast and authorNamesLastFirst columns from the libraryItems table', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const libraryItemsTable = await queryInterface.describeTable('libraryItems')
|
||||
expect(libraryItemsTable.authorNamesFirstLast).to.not.exist
|
||||
expect(libraryItemsTable.authorNamesLastFirst).to.not.exist
|
||||
|
||||
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 },
|
||||
{ id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('should remove the triggers from the libraryItems table', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)
|
||||
expect(count).to.equal(0)
|
||||
|
||||
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)
|
||||
expect(count2).to.equal(0)
|
||||
|
||||
const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)
|
||||
expect(count3).to.equal(0)
|
||||
})
|
||||
|
||||
it('should remove the indexes from the libraryItems table', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)
|
||||
expect(count).to.equal(0)
|
||||
|
||||
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)
|
||||
expect(count2).to.equal(0)
|
||||
})
|
||||
|
||||
it('should remove the index on publishedAt from the podcastEpisodes table', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)
|
||||
expect(count).to.equal(0)
|
||||
})
|
||||
|
||||
it('should be idempotent', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const libraryItemsTable = await queryInterface.describeTable('libraryItems')
|
||||
expect(libraryItemsTable.authorNamesFirstLast).to.not.exist
|
||||
expect(libraryItemsTable.authorNamesLastFirst).to.not.exist
|
||||
|
||||
const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 },
|
||||
{ id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 }
|
||||
])
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)
|
||||
expect(count).to.equal(0)
|
||||
|
||||
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)
|
||||
expect(count2).to.equal(0)
|
||||
|
||||
const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)
|
||||
expect(count3).to.equal(0)
|
||||
|
||||
const [[{ count: count4 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)
|
||||
expect(count4).to.equal(0)
|
||||
|
||||
const [[{ count: count5 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)
|
||||
expect(count5).to.equal(0)
|
||||
|
||||
const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)
|
||||
expect(count6).to.equal(0)
|
||||
})
|
||||
})
|
||||
})
|
@ -3,8 +3,8 @@ const expect = chai.expect
|
||||
const { parseOpfMetadataXML } = require('../../../../server/utils/parsers/parseOpfMetadata')
|
||||
|
||||
describe('parseOpfMetadata - test series', async () => {
|
||||
it('test one series', async () => {
|
||||
const opf = `
|
||||
it('test one series', async () => {
|
||||
const opf = `
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid">
|
||||
<metadata>
|
||||
@ -13,12 +13,12 @@ describe('parseOpfMetadata - test series', async () => {
|
||||
</metadata>
|
||||
</package>
|
||||
`
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([{ "name": "Serie", "sequence": "1" }])
|
||||
})
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([{ name: 'Serie', sequence: '1' }])
|
||||
})
|
||||
|
||||
it('test more then 1 series - in correct order', async () => {
|
||||
const opf = `
|
||||
it('test more then 1 series - in correct order', async () => {
|
||||
const opf = `
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid">
|
||||
<metadata>
|
||||
@ -31,16 +31,16 @@ describe('parseOpfMetadata - test series', async () => {
|
||||
</metadata>
|
||||
</package>
|
||||
`
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([
|
||||
{ "name": "Serie 1", "sequence": "1" },
|
||||
{ "name": "Serie 2", "sequence": "2" },
|
||||
{ "name": "Serie 3", "sequence": "3" },
|
||||
])
|
||||
})
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([
|
||||
{ name: 'Serie 1', sequence: '1' },
|
||||
{ name: 'Serie 2', sequence: '2' },
|
||||
{ name: 'Serie 3', sequence: '3' }
|
||||
])
|
||||
})
|
||||
|
||||
it('test messed order of series content and index', async () => {
|
||||
const opf = `
|
||||
it('test messed order of series content and index', async () => {
|
||||
const opf = `
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid">
|
||||
<metadata>
|
||||
@ -52,15 +52,15 @@ describe('parseOpfMetadata - test series', async () => {
|
||||
</metadata>
|
||||
</package>
|
||||
`
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([
|
||||
{ "name": "Serie 1", "sequence": "1" },
|
||||
{ "name": "Serie 3", "sequence": null },
|
||||
])
|
||||
})
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([
|
||||
{ name: 'Serie 1', sequence: '1' },
|
||||
{ name: 'Serie 3', sequence: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('test different values of series content and index', async () => {
|
||||
const opf = `
|
||||
it('test different values of series content and index', async () => {
|
||||
const opf = `
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid">
|
||||
<metadata>
|
||||
@ -73,16 +73,16 @@ describe('parseOpfMetadata - test series', async () => {
|
||||
</metadata>
|
||||
</package>
|
||||
`
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([
|
||||
{ "name": "Serie 1", "sequence": null },
|
||||
{ "name": "Serie 2", "sequence": "abc" },
|
||||
{ "name": "Serie 3", "sequence": null },
|
||||
])
|
||||
})
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([
|
||||
{ name: 'Serie 1', sequence: null },
|
||||
{ name: 'Serie 2', sequence: 'abc' },
|
||||
{ name: 'Serie 3', sequence: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('test empty series content', async () => {
|
||||
const opf = `
|
||||
it('test empty series content', async () => {
|
||||
const opf = `
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid">
|
||||
<metadata>
|
||||
@ -91,12 +91,12 @@ describe('parseOpfMetadata - test series', async () => {
|
||||
</metadata>
|
||||
</package>
|
||||
`
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([])
|
||||
})
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('test series and index using an xml namespace', async () => {
|
||||
const opf = `
|
||||
it('test series and index using an xml namespace', async () => {
|
||||
const opf = `
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<ns0:package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid">
|
||||
<ns0:metadata>
|
||||
@ -105,14 +105,12 @@ describe('parseOpfMetadata - test series', async () => {
|
||||
</ns0:metadata>
|
||||
</ns0:package>
|
||||
`
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([
|
||||
{ "name": "Serie 1", "sequence": null }
|
||||
])
|
||||
})
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([{ name: 'Serie 1', sequence: null }])
|
||||
})
|
||||
|
||||
it('test series and series index not directly underneath', async () => {
|
||||
const opf = `
|
||||
it('test series and series index not directly underneath', async () => {
|
||||
const opf = `
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid">
|
||||
<metadata>
|
||||
@ -122,9 +120,21 @@ describe('parseOpfMetadata - test series', async () => {
|
||||
</metadata>
|
||||
</package>
|
||||
`
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([
|
||||
{ "name": "Serie 1", "sequence": "1" }
|
||||
])
|
||||
})
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.series).to.deep.equal([{ name: 'Serie 1', sequence: '1' }])
|
||||
})
|
||||
|
||||
it('test author is parsed from refines meta', async () => {
|
||||
const opf = `
|
||||
<package version="3.0" unique-identifier="uuid_id" prefix="rendition: http://www.idpf.org/vocab/rendition/#" xmlns="http://www.idpf.org/2007/opf">
|
||||
<metadata>
|
||||
<dc:creator id="create1">Nevil Shute</dc:creator>
|
||||
<meta refines="#create1" property="role" scheme="marc:relators">aut</meta>
|
||||
<meta refines="#create1" property="file-as">Shute, Nevil</meta>
|
||||
</metadata>
|
||||
</package>
|
||||
`
|
||||
const parsedOpf = await parseOpfMetadataXML(opf)
|
||||
expect(parsedOpf.authors).to.deep.equal(['Nevil Shute'])
|
||||
})
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user