mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-03 23:36:17 +02:00
fix: rework content tier system to dynamically determine install status
Removes the InstalledTier model and instead checks presence of files on-the-fly. Avoid broken state by handling on the server-side vs. marking as installed by client-side API call
This commit is contained in:
parent
fcc749ec57
commit
36b6d8ed7a
|
|
@ -3,7 +3,6 @@ import {
|
||||||
downloadCollectionValidator,
|
downloadCollectionValidator,
|
||||||
filenameParamValidator,
|
filenameParamValidator,
|
||||||
remoteDownloadWithMetadataValidator,
|
remoteDownloadWithMetadataValidator,
|
||||||
saveInstalledTierValidator,
|
|
||||||
selectWikipediaValidator,
|
selectWikipediaValidator,
|
||||||
} from '#validators/common'
|
} from '#validators/common'
|
||||||
import { listRemoteZimValidator } from '#validators/zim'
|
import { listRemoteZimValidator } from '#validators/zim'
|
||||||
|
|
@ -56,12 +55,6 @@ export default class ZimController {
|
||||||
return { success }
|
return { success }
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveInstalledTier({ request }: HttpContext) {
|
|
||||||
const payload = await request.validateUsing(saveInstalledTierValidator)
|
|
||||||
await this.zimService.saveInstalledTier(payload.categorySlug, payload.tierSlug)
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete({ request, response }: HttpContext) {
|
async delete({ request, response }: HttpContext) {
|
||||||
const payload = await request.validateUsing(filenameParamValidator)
|
const payload = await request.validateUsing(filenameParamValidator)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
|
||||||
|
|
||||||
export default class InstalledTier extends BaseModel {
|
|
||||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
|
||||||
|
|
||||||
@column({ isPrimary: true })
|
|
||||||
declare id: number
|
|
||||||
|
|
||||||
@column()
|
|
||||||
declare category_slug: string
|
|
||||||
|
|
||||||
@column()
|
|
||||||
declare tier_slug: string
|
|
||||||
|
|
||||||
@column.dateTime({ autoCreate: true })
|
|
||||||
declare created_at: DateTime
|
|
||||||
|
|
||||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
|
||||||
declare updated_at: DateTime
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,6 @@ import vine from '@vinejs/vine'
|
||||||
import { curatedCategoriesFileSchema, curatedCollectionsFileSchema, wikipediaOptionsFileSchema } from '#validators/curated_collections'
|
import { curatedCategoriesFileSchema, curatedCollectionsFileSchema, wikipediaOptionsFileSchema } from '#validators/curated_collections'
|
||||||
import CuratedCollection from '#models/curated_collection'
|
import CuratedCollection from '#models/curated_collection'
|
||||||
import CuratedCollectionResource from '#models/curated_collection_resource'
|
import CuratedCollectionResource from '#models/curated_collection_resource'
|
||||||
import InstalledTier from '#models/installed_tier'
|
|
||||||
import WikipediaSelection from '#models/wikipedia_selection'
|
import WikipediaSelection from '#models/wikipedia_selection'
|
||||||
import ZimFileMetadata from '#models/zim_file_metadata'
|
import ZimFileMetadata from '#models/zim_file_metadata'
|
||||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||||
|
|
@ -329,29 +328,65 @@ export class ZimService implements IZimService {
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Look up installed tiers for all categories
|
// Dynamically determine installed tier for each category
|
||||||
const installedTiers = await InstalledTier.all()
|
const categoriesWithStatus = await Promise.all(
|
||||||
const installedTierMap = new Map(
|
validated.categories.map(async (category) => {
|
||||||
installedTiers.map((t) => [t.category_slug, t.tier_slug])
|
const installedTierSlug = await this.getInstalledTierForCategory(category)
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
installedTierSlug,
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add installedTierSlug to each category
|
return categoriesWithStatus
|
||||||
return validated.categories.map((category) => ({
|
|
||||||
...category,
|
|
||||||
installedTierSlug: installedTierMap.get(category.slug),
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[ZimService] Failed to fetch curated categories:`, error)
|
logger.error(`[ZimService] Failed to fetch curated categories:`, error)
|
||||||
throw new Error('Failed to fetch curated categories or invalid format was received')
|
throw new Error('Failed to fetch curated categories or invalid format was received')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveInstalledTier(categorySlug: string, tierSlug: string): Promise<void> {
|
/**
|
||||||
await InstalledTier.updateOrCreate(
|
* Dynamically determines which tier is installed for a category by checking
|
||||||
{ category_slug: categorySlug },
|
* which tier's resources are all downloaded. Returns the highest tier that
|
||||||
{ tier_slug: tierSlug }
|
* is fully installed (considering that higher tiers include lower tier resources)
|
||||||
)
|
*/
|
||||||
logger.info(`[ZimService] Saved installed tier: ${categorySlug} -> ${tierSlug}`)
|
private async getInstalledTierForCategory(category: CuratedCategory): Promise<string | undefined> {
|
||||||
|
const { files: diskFiles } = await this.list()
|
||||||
|
const diskFilenames = new Set(diskFiles.map((f) => f.name))
|
||||||
|
|
||||||
|
// Get all CuratedCollectionResources marked as downloaded
|
||||||
|
const downloadedResources = await CuratedCollectionResource.query()
|
||||||
|
.where('downloaded', true)
|
||||||
|
.select('url')
|
||||||
|
const downloadedUrls = new Set(downloadedResources.map((r) => r.url))
|
||||||
|
|
||||||
|
// Check each tier from highest to lowest (assuming tiers are ordered from low to high)
|
||||||
|
// We check in reverse to find the highest fully-installed tier
|
||||||
|
const reversedTiers = [...category.tiers].reverse()
|
||||||
|
|
||||||
|
for (const tier of reversedTiers) {
|
||||||
|
const allResourcesInstalled = tier.resources.every((resource) => {
|
||||||
|
// Check if resource is marked as downloaded in database
|
||||||
|
if (downloadedUrls.has(resource.url)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check if file exists on disk (for resources not tracked in CuratedCollectionResource)
|
||||||
|
const filename = resource.url.split('/').pop()
|
||||||
|
if (filename && diskFilenames.has(filename)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allResourcesInstalled && tier.resources.length > 0) {
|
||||||
|
return tier.slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
|
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
|
||||||
|
|
@ -372,18 +407,26 @@ export class ZimService implements IZimService {
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const collection of validated.collections) {
|
for (const collection of validated.collections) {
|
||||||
const collectionResult = await CuratedCollection.updateOrCreate(
|
const { resources, ...restCollection } = collection; // we'll handle resources separately
|
||||||
{ slug: collection.slug },
|
|
||||||
|
// Upsert the collection itself
|
||||||
|
await CuratedCollection.updateOrCreate(
|
||||||
|
{ slug: restCollection.slug },
|
||||||
{
|
{
|
||||||
...collection,
|
...restCollection,
|
||||||
type: 'zim',
|
type: 'zim',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.info(`[ZimService] Upserted curated collection: ${collection.slug}`)
|
logger.info(`[ZimService] Upserted curated collection: ${restCollection.slug}`)
|
||||||
|
|
||||||
|
// Upsert collection's resources
|
||||||
|
const resourcesResult = await CuratedCollectionResource.updateOrCreateMany('url', resources.map((res) => ({
|
||||||
|
...res,
|
||||||
|
curated_collection_slug: restCollection.slug, // add the foreign key
|
||||||
|
})))
|
||||||
|
|
||||||
await collectionResult.related('resources').createMany(collection.resources)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[ZimService] Upserted ${collection.resources.length} resources for collection: ${collection.slug}`
|
`[ZimService] Upserted ${resourcesResult.length} resources for collection: ${restCollection.slug}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,6 @@ export const downloadCollectionValidator = vine.compile(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
export const saveInstalledTierValidator = vine.compile(
|
|
||||||
vine.object({
|
|
||||||
categorySlug: vine.string().trim().minLength(1),
|
|
||||||
tierSlug: vine.string().trim().minLength(1),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export const selectWikipediaValidator = vine.compile(
|
export const selectWikipediaValidator = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
optionId: vine.string().trim().minLength(1),
|
optionId: vine.string().trim().minLength(1),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||||
|
|
||||||
|
export default class extends BaseSchema {
|
||||||
|
protected tableName = 'installed_tiers'
|
||||||
|
|
||||||
|
async up() {
|
||||||
|
this.schema.dropTableIfExists(this.tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {
|
||||||
|
// Recreate the table if we need to rollback
|
||||||
|
this.schema.createTable(this.tableName, (table) => {
|
||||||
|
table.increments('id')
|
||||||
|
table.string('category_slug').notNullable().unique()
|
||||||
|
table.string('tier_slug').notNullable()
|
||||||
|
table.timestamp('created_at', { useTz: true })
|
||||||
|
table.timestamp('updated_at', { useTz: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -354,16 +354,6 @@ class API {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveInstalledTier(categorySlug: string, tierSlug: string) {
|
|
||||||
return catchInternal(async () => {
|
|
||||||
const response = await this.client.post<{ success: boolean }>('/zim/save-installed-tier', {
|
|
||||||
categorySlug,
|
|
||||||
tierSlug,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
async listDocs() {
|
async listDocs() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')
|
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')
|
||||||
|
|
|
||||||
|
|
@ -403,12 +403,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
|
|
||||||
await Promise.all(downloadPromises)
|
await Promise.all(downloadPromises)
|
||||||
|
|
||||||
// Save installed tiers for each selected category
|
|
||||||
const tierSavePromises = Array.from(selectedTiers.entries()).map(
|
|
||||||
([categorySlug, tier]) => api.saveInstalledTier(categorySlug, tier.slug)
|
|
||||||
)
|
|
||||||
await Promise.all(tierSavePromises)
|
|
||||||
|
|
||||||
// Select Wikipedia option if one was chosen
|
// Select Wikipedia option if one was chosen
|
||||||
if (selectedWikipedia && selectedWikipedia !== wikipediaState?.currentSelection?.optionId) {
|
if (selectedWikipedia && selectedWikipedia !== wikipediaState?.currentSelection?.optionId) {
|
||||||
await api.selectWikipedia(selectedWikipedia)
|
await api.selectWikipedia(selectedWikipedia)
|
||||||
|
|
|
||||||
|
|
@ -241,15 +241,12 @@ export default function ZimRemoteExplorer() {
|
||||||
// Get all resources for this tier (including inherited ones)
|
// Get all resources for this tier (including inherited ones)
|
||||||
const resources = getAllResourcesForTier(tier, category.tiers)
|
const resources = getAllResourcesForTier(tier, category.tiers)
|
||||||
|
|
||||||
// Download each resource and save the installed tier
|
// Download each resource
|
||||||
try {
|
try {
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
await api.downloadRemoteZimFile(resource.url)
|
await api.downloadRemoteZimFile(resource.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the installed tier
|
|
||||||
await api.saveInstalledTier(category.slug, tier.slug)
|
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`,
|
message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ router
|
||||||
router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections'])
|
router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections'])
|
||||||
router.post('/download-remote', [ZimController, 'downloadRemote'])
|
router.post('/download-remote', [ZimController, 'downloadRemote'])
|
||||||
router.post('/download-collection', [ZimController, 'downloadCollection'])
|
router.post('/download-collection', [ZimController, 'downloadCollection'])
|
||||||
router.post('/save-installed-tier', [ZimController, 'saveInstalledTier'])
|
|
||||||
router.get('/wikipedia', [ZimController, 'getWikipediaState'])
|
router.get('/wikipedia', [ZimController, 'getWikipediaState'])
|
||||||
router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])
|
router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])
|
||||||
router.delete('/:filename', [ZimController, 'delete'])
|
router.delete('/:filename', [ZimController, 'delete'])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user