Member/Ecology revamp.
This commit is contained in:
parent
fc7ec52574
commit
59d6e97787
31 changed files with 1763 additions and 1010 deletions
|
|
@ -2,23 +2,35 @@ import * as z from 'zod'
|
|||
import WikiArticle from '../../../models/wikiArticle.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
|
||||
const wikiTagsSchema = z.object({
|
||||
tags: z.array(z.string())
|
||||
const wikiUpdateSchema = z.object({
|
||||
tags: z.array(z.string()).optional(),
|
||||
hidden: z.boolean().optional()
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const body = await validateBody(event, wikiTagsSchema)
|
||||
const body = await validateBody(event, wikiUpdateSchema)
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (body.tags === undefined && body.hidden === undefined) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Nothing to update' })
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
await validateTagSlugs(body.tags)
|
||||
const update = {}
|
||||
if (body.tags !== undefined) {
|
||||
await validateTagSlugs(body.tags)
|
||||
update.tags = body.tags
|
||||
}
|
||||
if (body.hidden !== undefined) {
|
||||
update.hidden = body.hidden
|
||||
}
|
||||
|
||||
const article = await WikiArticle.findByIdAndUpdate(
|
||||
id,
|
||||
{ tags: body.tags },
|
||||
update,
|
||||
{ new: true }
|
||||
)
|
||||
|
||||
|
|
|
|||
34
server/api/admin/wiki/batch-visibility.post.js
Normal file
34
server/api/admin/wiki/batch-visibility.post.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import * as z from 'zod'
|
||||
import WikiArticle from '../../../models/wikiArticle.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
|
||||
const batchVisibilitySchema = z.object({
|
||||
articleIds: z.array(z.string()).optional(),
|
||||
collection: z.string().optional(),
|
||||
hidden: z.boolean()
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const body = await validateBody(event, batchVisibilitySchema)
|
||||
|
||||
if (!body.articleIds && !body.collection) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Must provide either articleIds or collection'
|
||||
})
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
const filter = body.articleIds
|
||||
? { _id: { $in: body.articleIds } }
|
||||
: { collection: body.collection }
|
||||
|
||||
const result = await WikiArticle.updateMany(filter, {
|
||||
$set: { hidden: body.hidden }
|
||||
})
|
||||
|
||||
return { modified: result.modifiedCount || 0 }
|
||||
})
|
||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
const articles = await WikiArticle.find(filter)
|
||||
.select('collection title tags url outlineId publishedAt')
|
||||
.select('collection title tags hidden url outlineId publishedAt outlineUpdatedAt')
|
||||
.sort({ collection: 1, title: 1 })
|
||||
.lean()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import WikiArticle from '../../../models/wikiArticle.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import { fetchAllDocuments, extractSummary } from '../../../utils/outline.js'
|
||||
import { syncWikiArticles } from '../../../utils/syncWikiArticles.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
// Fetch ALL documents first — if this fails, no DB changes happen
|
||||
let documents
|
||||
try {
|
||||
documents = await fetchAllDocuments()
|
||||
return await syncWikiArticles()
|
||||
} catch (err) {
|
||||
console.error('[wiki-sync] Outline fetch failed:', err)
|
||||
throw createError({
|
||||
|
|
@ -16,68 +12,4 @@ export default defineEventHandler(async (event) => {
|
|||
statusMessage: err.statusMessage || 'Failed to fetch documents from Outline'
|
||||
})
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
const fetchedOutlineIds = new Set(documents.map((doc) => doc.id))
|
||||
|
||||
// Get all existing articles for comparison
|
||||
const existing = await WikiArticle.find({}, 'outlineId publishedAt')
|
||||
const existingByOutlineId = new Map(
|
||||
existing.map((a) => [a.outlineId, a])
|
||||
)
|
||||
|
||||
let created = 0
|
||||
let updated = 0
|
||||
let deleted = 0
|
||||
let errors = 0
|
||||
|
||||
// Upsert each fetched document
|
||||
for (const doc of documents) {
|
||||
try {
|
||||
const articleData = {
|
||||
title: doc.title,
|
||||
collection: doc.collection?.name || null,
|
||||
url: doc.url,
|
||||
summary: extractSummary(doc.text),
|
||||
publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : new Date(doc.createdAt),
|
||||
permission: doc.permission || null,
|
||||
lastSyncedAt: new Date(),
|
||||
outlineUpdatedAt: doc.updatedAt ? new Date(doc.updatedAt) : null
|
||||
}
|
||||
|
||||
const result = await WikiArticle.findOneAndUpdate(
|
||||
{ outlineId: doc.id },
|
||||
{ $set: articleData },
|
||||
{ upsert: true, new: true, rawResult: true }
|
||||
)
|
||||
|
||||
if (result.lastErrorObject?.updatedExisting) {
|
||||
updated++
|
||||
} else {
|
||||
created++
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[wiki-sync] Error upserting doc ${doc.id}:`, err)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-delete articles no longer in Outline
|
||||
for (const [outlineId, article] of existingByOutlineId) {
|
||||
if (!fetchedOutlineIds.has(outlineId) && article.publishedAt !== null) {
|
||||
try {
|
||||
await WikiArticle.findOneAndUpdate(
|
||||
{ outlineId },
|
||||
{ $set: { publishedAt: null, lastSyncedAt: new Date() } }
|
||||
)
|
||||
deleted++
|
||||
} catch (err) {
|
||||
console.error(`[wiki-sync] Error soft-deleting ${outlineId}:`, err)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, deleted, errors }
|
||||
})
|
||||
|
|
|
|||
20
server/api/wiki/recent.get.js
Normal file
20
server/api/wiki/recent.get.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import WikiArticle from '../../models/wikiArticle.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB()
|
||||
|
||||
const query = getQuery(event)
|
||||
const limit = Math.min(Math.max(parseInt(query.limit) || 4, 1), 10)
|
||||
|
||||
const articles = await WikiArticle.find({
|
||||
publishedAt: { $ne: null },
|
||||
hidden: { $ne: true }
|
||||
})
|
||||
.sort({ publishedAt: -1 })
|
||||
.limit(limit)
|
||||
.select('title url publishedAt')
|
||||
.lean()
|
||||
|
||||
return articles
|
||||
})
|
||||
|
|
@ -8,6 +8,7 @@ const wikiArticleSchema = new mongoose.Schema(
|
|||
url: { type: String, required: true },
|
||||
summary: String,
|
||||
tags: [{ type: String }],
|
||||
hidden: { type: Boolean, default: false },
|
||||
publishedAt: Date,
|
||||
permission: String,
|
||||
lastSyncedAt: Date,
|
||||
|
|
|
|||
26
server/plugins/wiki-sync.js
Normal file
26
server/plugins/wiki-sync.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { syncWikiArticles } from '../utils/syncWikiArticles.js'
|
||||
|
||||
const INTERVAL_MS = 86400000 // 24 hours
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
if (process.env.NODE_ENV === 'test') return
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
if (!config.outlineApiKey) {
|
||||
console.warn('[wiki-sync] No Outline API key configured, skipping background sync')
|
||||
return
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const result = await syncWikiArticles()
|
||||
console.log(`[wiki-sync] Done: ${result.created} created, ${result.updated} updated, ${result.deleted} removed, ${result.errors} errors`)
|
||||
} catch (err) {
|
||||
console.error('[wiki-sync] Unhandled error:', err.message || err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run on server start, then every 24 hours
|
||||
run()
|
||||
setInterval(run, INTERVAL_MS)
|
||||
})
|
||||
|
|
@ -36,21 +36,22 @@ export async function fetchAllDocuments() {
|
|||
}
|
||||
|
||||
const documents = []
|
||||
let path = '/documents.list'
|
||||
let offset = 0
|
||||
const limit = 25
|
||||
|
||||
while (path) {
|
||||
const response = await fetch(`${OUTLINE_API_BASE}${path}`, {
|
||||
while (true) {
|
||||
const response = await fetch(`${OUTLINE_API_BASE}/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({ limit: 25 })
|
||||
body: JSON.stringify({ offset, limit })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`[outline] POST ${path} ${response.status} ${errorText}`)
|
||||
console.error(`[outline] POST /documents.list offset=${offset} ${response.status} ${errorText}`)
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: 'Outline API error'
|
||||
|
|
@ -58,10 +59,46 @@ export async function fetchAllDocuments() {
|
|||
}
|
||||
|
||||
const data = await response.json()
|
||||
documents.push(...(data.data || []))
|
||||
const page = data.data || []
|
||||
documents.push(...page)
|
||||
|
||||
path = data.pagination?.nextPath || null
|
||||
if (page.length < limit) break
|
||||
offset += limit
|
||||
}
|
||||
|
||||
return documents
|
||||
}
|
||||
|
||||
export async function fetchCollections() {
|
||||
const config = useRuntimeConfig()
|
||||
const apiKey = config.outlineApiKey
|
||||
|
||||
if (!apiKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Outline API key not configured'
|
||||
})
|
||||
}
|
||||
|
||||
const response = await fetch(`${OUTLINE_API_BASE}/collections.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({ limit: 100 })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`[outline] POST /collections.list ${response.status} ${errorText}`)
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: 'Outline API error'
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const collections = data.data || []
|
||||
return new Map(collections.map(c => [c.id, c.name]))
|
||||
}
|
||||
|
|
|
|||
72
server/utils/syncWikiArticles.js
Normal file
72
server/utils/syncWikiArticles.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import WikiArticle from '../models/wikiArticle.js'
|
||||
import { connectDB } from './mongoose.js'
|
||||
import { fetchAllDocuments, fetchCollections, extractSummary } from './outline.js'
|
||||
|
||||
export async function syncWikiArticles() {
|
||||
const [documents, collectionMap] = await Promise.all([
|
||||
fetchAllDocuments(),
|
||||
fetchCollections()
|
||||
])
|
||||
|
||||
await connectDB()
|
||||
|
||||
const fetchedOutlineIds = new Set(documents.map((doc) => doc.id))
|
||||
|
||||
const existing = await WikiArticle.find({}, 'outlineId publishedAt')
|
||||
const existingByOutlineId = new Map(
|
||||
existing.map((a) => [a.outlineId, a])
|
||||
)
|
||||
|
||||
let created = 0
|
||||
let updated = 0
|
||||
let deleted = 0
|
||||
let errors = 0
|
||||
|
||||
for (const doc of documents) {
|
||||
try {
|
||||
// Only $set fields from Outline — tags are never touched
|
||||
const articleData = {
|
||||
title: doc.title,
|
||||
collection: collectionMap.get(doc.collectionId) || null,
|
||||
url: doc.url?.startsWith('/') ? `https://wiki.ghostguild.org${doc.url}` : doc.url,
|
||||
summary: extractSummary(doc.text),
|
||||
publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : new Date(doc.createdAt),
|
||||
permission: doc.permission || null,
|
||||
lastSyncedAt: new Date(),
|
||||
outlineUpdatedAt: doc.updatedAt ? new Date(doc.updatedAt) : null
|
||||
}
|
||||
|
||||
const result = await WikiArticle.findOneAndUpdate(
|
||||
{ outlineId: doc.id },
|
||||
{ $set: articleData },
|
||||
{ upsert: true, new: true, rawResult: true }
|
||||
)
|
||||
|
||||
if (result.lastErrorObject?.updatedExisting) {
|
||||
updated++
|
||||
} else {
|
||||
created++
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[wiki-sync] Error upserting doc ${doc.id}:`, err)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
for (const [outlineId, article] of existingByOutlineId) {
|
||||
if (!fetchedOutlineIds.has(outlineId) && article.publishedAt !== null) {
|
||||
try {
|
||||
await WikiArticle.findOneAndUpdate(
|
||||
{ outlineId },
|
||||
{ $set: { publishedAt: null, lastSyncedAt: new Date() } }
|
||||
)
|
||||
deleted++
|
||||
} catch (err) {
|
||||
console.error(`[wiki-sync] Error soft-deleting ${outlineId}:`, err)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, deleted, errors }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue