feat(wiki): add Outline utility and wiki sync API
This commit is contained in:
parent
3797ff7925
commit
905b5155e2
7 changed files with 466 additions and 2 deletions
84
server/api/admin/wiki/sync.post.js
Normal file
84
server/api/admin/wiki/sync.post.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import WikiArticle from '../../../models/wikiArticle.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
import { fetchAllDocuments, extractSummary } from '../../../utils/outline.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()
|
||||
} catch (err) {
|
||||
console.error('[wiki-sync] Outline fetch failed:', err)
|
||||
throw createError({
|
||||
statusCode: err.statusCode || 502,
|
||||
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/models/wikiArticle.js
Normal file
20
server/models/wikiArticle.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import mongoose from 'mongoose'
|
||||
|
||||
const wikiArticleSchema = new mongoose.Schema(
|
||||
{
|
||||
outlineId: { type: String, unique: true, required: true },
|
||||
title: { type: String, required: true },
|
||||
collection: String,
|
||||
url: { type: String, required: true },
|
||||
summary: String,
|
||||
tags: [{ type: String }],
|
||||
publishedAt: Date,
|
||||
permission: String,
|
||||
lastSyncedAt: Date,
|
||||
outlineUpdatedAt: Date
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
export default mongoose.models.WikiArticle ||
|
||||
mongoose.model('WikiArticle', wikiArticleSchema)
|
||||
67
server/utils/outline.js
Normal file
67
server/utils/outline.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
const OUTLINE_API_BASE = 'https://wiki.ghostguild.org/api'
|
||||
|
||||
/**
|
||||
* Strip HTML tags and truncate to 200 characters at a word boundary.
|
||||
* If the stripped text is <= 200 chars, returns it as-is.
|
||||
*/
|
||||
export function extractSummary(text) {
|
||||
if (!text) return ''
|
||||
|
||||
const stripped = text.replace(/<[^>]*>/g, '').trim()
|
||||
|
||||
if (stripped.length <= 200) return stripped
|
||||
|
||||
const truncated = stripped.slice(0, 200)
|
||||
const lastSpace = truncated.lastIndexOf(' ')
|
||||
|
||||
// If no space found at all, return the full 200 chars (single long word)
|
||||
if (lastSpace <= 0) return truncated
|
||||
|
||||
return truncated.slice(0, lastSpace)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all documents from Outline wiki, paginating through all pages.
|
||||
* Throws on any page failure — caller is responsible for abort logic.
|
||||
*/
|
||||
export async function fetchAllDocuments() {
|
||||
const config = useRuntimeConfig()
|
||||
const apiKey = config.outlineApiKey
|
||||
|
||||
if (!apiKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Outline API key not configured'
|
||||
})
|
||||
}
|
||||
|
||||
const documents = []
|
||||
let path = '/documents.list'
|
||||
|
||||
while (path) {
|
||||
const response = await fetch(`${OUTLINE_API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({ limit: 25 })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`[outline] POST ${path} ${response.status} ${errorText}`)
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: 'Outline API error'
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
documents.push(...(data.data || []))
|
||||
|
||||
path = data.pagination?.nextPath || null
|
||||
}
|
||||
|
||||
return documents
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue