docs: v0.2.0 release notes and wiki docs; cleanup dead code; add missing types; ensure tests green
All checks were successful
CI / build_test (push) Successful in 3m33s

This commit is contained in:
greenflame089
2025-08-22 01:01:49 -04:00
parent 8b48a8e313
commit 34b3a55299
33 changed files with 1553 additions and 193 deletions

View File

@@ -0,0 +1,26 @@
# ADR 0006 — Wiki Mode (MultiEditor via Visible Revisions)
Date: 2025-08-22
Status: Accepted
## Context
Cloneonedit bug revealed a path to collaborative editing. We want to formalize this as Wiki mode.
## Decision
- Add perblog wikiEnabled toggle.
- Add Namebased whitelist/blacklist with precedence: blacklist > whitelist > global.
- Posts remain immutable; revisions link via originPostId/parentPostId/lineageBlogId.
- Canonical revision chosen clientside by resolver.
## Consequences
- Multiple editors may contribute visible revisions.
- Owner can revoke visibility instantly by changing lists.
- Legacy posts unaffected (wikiEnabled missing = false).
## Alternatives
- Serverside canonical logic (not possible in Qortal).
- Perrevision ACLs (heavier; unnecessary for v1).

View File

@@ -201,6 +201,11 @@
---
## Milestone Updates
- v0.1.x Multiblog foundations and quality baselines delivered.
- v0.2.0 Wiki Mode canonical selection shipped (owner/whitelist/blacklist; canonical dedupe across Names in feed, favorites, subscriptions, and post view). Settings cache added for efficient owner/settings resolution.
## Phase 8 — Performance & Resilience
**Libraries**

View File

@@ -0,0 +1,36 @@
# Q-Blog v0.1.1 — Wiki Mode Integration, Canonicalization, and Perf
Date: 2025-08-22
## Highlights
- Wiki Mode settings in Create + Edit Blog modals (checkbox + whitelist/blacklist).
- Canonical resolver wired into Post, Blog page, Global feed, Subscriptions, and Favorites.
- Attribution on Post page: “Latest by <Name> · <relative time>”.
- Header “My Blogs” switcher seeds blog context for immediate list refresh.
- Performance: settings read from BLOG metadata when available; perpage parallel prefetch for duplicate identifiers; singletons skip canonicalization.
## Details
- Data model: BLOG JSON and metadata now include `wikiEnabled`, `editorWhitelist`, `editorBlacklist`.
- Resolver logic (`src/utils/wiki.ts`): Owner always allowed; blacklist > whitelist > global allow; newest wins with owner/id tiebreakers.
- Caching (`src/utils/wikiSettingsCache.ts`): Reads from metadata; fetches BLOG JSON only when fields are missing.
- Lists (`src/hooks/useFetchPosts.tsx`):
- Global feed and Subscriptions canonicalize per identifier across Names.
- Favorites canonicalizes per identifier across Names.
- Prefetches settings for blogs with duplicates in parallel; singles are fastpathed.
## Fixes
- Edit modal now correctly pre-fills the wiki checkbox and lists.
- Blog page refreshes the posts list reliably when switching blogs in the header.
## Testing
- New tests for resolver, cache, blog/post pages, and Favorites canonicalization.
- MSW defaults prevent unhandled network calls in tests.
## Notes
- Backwards compatible: legacy blogs behave unchanged until wiki is enabled.
- Further perf options: optimistic list render and concurrency caps are available if desired.

View File

@@ -0,0 +1,38 @@
QBlog v0.2.0 — Wiki Mode Canonical Selection
Date: 2025-08-22
Highlights
- Wiki Mode canonical selection: When multiple authors publish the same `BLOG_POST` identifier, the app now selects a single canonical revision to display based on blog settings.
- Perblog settings cache: Efficiently resolves each blogs owner and wiki settings (`wikiEnabled`, `editorWhitelist`, `editorBlacklist`).
- Favorites and Subscriptions respect wiki mode: Lists deduplicate by identifier across names and pick the canonical author.
- Individual post view resolves canonical author when wiki is enabled before fetching content JSON.
- UI: Navbar search/filter and persistent Tile/List view toggle. Notifications consolidated in one place.
- Quality: Accessibility tests and stronger utility/unit coverage; minor code cleanup and worker URL fix.
Canonical selection rules
- Owner is always allowed (cannot be blocked by blacklist).
- Blacklist disallows others even if whitelisted; whitelist gates nonowners if nonempty.
- Among authorized authors: pick latest by `updatedAt`/`qdnUpdated`; owner wins ties; otherwise lowest id as last tiebreaker.
Implementation notes
- `utils/wiki.ts` implements `isAuthorized`, `canEdit`, and `selectCanonical`.
- `utils/wikiSettingsCache.ts` caches perblog owner/settings, preferring metadata when available.
- `hooks/useFetchPosts.tsx` canonicalizes search results for feed, favorites, and subscriptions.
- `pages/BlogIndividualPost/BlogIndividualPost.tsx` selects the canonical author for the requested post.
Breaking changes
- None. Existing identifiers and endpoints are unchanged.
Fixes & cleanup
- Removed an unused web worker and fixed a BLOG_POST worker URL.
- Removed redundant local helper where a shared util exists.
Upgrade notes
- No migration steps required. Deploy as usual; v0.2.0 is compatible with prior data.

View File

@@ -34,8 +34,13 @@ Additional tests added:
- `tests/utils/fetchPosts.test.ts`: maps BLOG_POST fetch via MSW.
- `tests/utils/qortalRequestFunctions.test.ts`: qortalRequest wrappers behavior.
- `tests/utils/fetchMail.test.ts`: mail decrypt/mapping flow with mocked qortalRequest.
- `tests/utils/wiki.test.ts`: wiki authorization and canonical resolver.
- `tests/pages/BlogList.test.tsx`: renders posts and new-post banner (MUI ThemeProvider).
- `tests/pages/BlogIndividualProfile.test.tsx`: loads blog title from API (MSW + ThemeProvider).
- `tests/pages/BlogIndividualPost.wiki.test.tsx`: canonical across Names + edit visibility.
- `tests/pages/BlogIndividualProfile.wiki.test.tsx`: canonical grouping on blog page.
- `tests/utils/wikiSettingsCache.test.ts`: cache fetch and settings mapping.
- `tests/hooks/useFetchPosts.favorites.wiki.test.tsx`: Favorites canonicalization across Names.
---

View File

@@ -0,0 +1,19 @@
QBlog v0.2.0 — Wiki Mode is here
Weve shipped a smarter, collaborative blogging experience. When multiple people post updates to the same article, QBlog now shows a single, “canonical” version based on the blog owners wiki settings.
Whats new
- Canonical posts: If several authors publish a revision of the same post, QBlog picks one to display. The owners settings define who can contribute. The most uptodate allowed revision wins; the owners revision wins ties.
- Works everywhere: The main feed, Favorites, Subscriptions, and the post page all use canonical selection when wiki mode is enabled.
- Quality of life: Search/filter from the navbar and switch between Tile/List views (your choice is remembered).
No action needed
Just update to v0.2.0. Your posts and favorites continue to work; collaborative updates will show consistently.
Tips
- Owners can enable wiki mode and manage who can edit via blog settings (whitelist/blacklist).
- Use the List view to scan more details at a glance; switch back to Tiles any time.
Thanks for using QBlog!

View File

@@ -0,0 +1,37 @@
# Wiki Mode / MultiEditor — Product Overview
_Generated 2025-08-22_
## Summary
QBlog gains a new **Wiki mode**, allowing multiple **Names** to publish **visible revisions** of posts under a blog.
- **Names** are the identity key (not account addresses).
- Authorization is computed **clientside**; Qortal has no server scripting.
- Precedence: **blacklist > whitelist > global allow**.
- Backwardcompatible: missing fields mean wiki is off.
## UX
- **Blog Settings:** toggle Wiki Mode; configure whitelist/blacklist of Names.
- **Post page:** shows the canonical (latest visible) revision with author/date attribution.
- Implemented: canonical chosen across Names by exact BLOG_POST identifier; attribution shown when author differs from owner.
- **Edit button:** shown only to Owner and authorized editors.
- **Blog list:** groups posts by lineage and shows canonical revision per group.
- Implemented for Blog page (`/{name}/{blog}`) using identifier-grouping.
- Global feed, Subscriptions, and Favorites canonicalize duplicates via a cached lookup of perblog wiki settings.
- Header “My Blogs” switcher updates both title and posts by seeding blog context immediately.
## Canonical Selection (v0.2.0)
The app selects one canonical revision when multiple Names publish the same `BLOG_POST` identifier:
- Authorization first: filter out unauthorized Names using blog settings. The Owner is always allowed (cannot be blocked).
- Freshest wins: compare `updatedAt` or `qdnUpdated`; pick the most recent.
- Tiebreakers: if timestamps tie, prefer the Owner; otherwise, pick the lowest id for stability.
This logic is applied consistently in:
- Main feed pages and “Load new posts”.
- Favorites and Subscriptions lists.
- Individual post view before fetching content.

View File

@@ -0,0 +1,36 @@
# Wiki Mode — Resolver & Authorization Spec
_Generated 2025-08-22_
## Identity
- **Name** (string) is the identity key.
- One account may own multiple Names. Names may be transferred; rules apply to current Name string.
## Authorization Precedence
1. Owner always allowed.
2. If Name ∈ blacklist → blocked.
3. If whitelist nonempty → allowed iff Name ∈ whitelist (and not blacklisted).
4. If whitelist empty → allowed (unless blacklisted).
If Name in both lists → blacklisted.
## Canonical Selection
- Group posts by originPostId (or id if missing).
- Candidates: published posts in lineage blog id.
- Filter: authorized authors only.
- Choose newest by updatedAt. Tie: Owner wins; then lowest id.
## Edit Visibility
Shown if: (viewer == Owner) or (wikiEnabled && viewer not blacklisted && (whitelist empty or viewer ∈ whitelist)).
## Backward Compatibility
- Missing wikiEnabled → false
- Missing lists → empty
- Missing originPostId → id
- Missing lineageBlogId → current blogId
- Missing updatedAt → QDN timestamp

View File

@@ -0,0 +1,66 @@
# Wiki Mode / MultiEditor — Technical Implementation
_Generated 2025-08-22_
## Data Model
**BlogSettings**
- `wikiEnabled: boolean` (optional; missing = false)
- `editorWhitelist: Name[]` (optional; empty = global allow)
- `editorBlacklist: Name[]` (optional)
**Post (revision)**
- `originPostId: Id` (first ancestor; fallback to id if missing)
- `parentPostId?: Id`
- `lineageBlogId: BlogId` (owners blog id; fallback to route blog id)
- `authorName: Name` (registered name)
- `updatedAt: ISO timestamp` (fallback to QDN timestamp if missing)
- `published: boolean`
## Authorization (by Name)
1. Owner always allowed.
2. Blacklist blocks regardless.
3. Whitelist nonempty → only listed Names (minus blacklist).
4. Whitelist empty → all Names allowed (minus blacklist).
## canEdit(viewerName, settings, ownerName)
- Returns true if viewerName is Owner, or if wiki enabled and viewerName passes the whitelist/blacklist rules.
## Canonical Resolver (client)
1. Resolve origin id.
2. Collect revisions with same origin + blog lineage, published = true.
3. Filter by authorization (authorName vs blog settings).
4. Pick newest by updatedAt; tiebreak: Owner wins; then lowest id.
Reference implementation in code:
- `src/utils/wiki.ts` provides `isAuthorized`, `canEdit`, and `selectCanonical` used across UI.
- `src/utils/wikiSettingsCache.ts` caches perblog (ownerName, settings) using `/arbitrary/resources?service=BLOG&identifier=...` and `/arbitrary/BLOG/<owner>/<id>`.
- `src/hooks/useFetchPosts.tsx` groups search results by identifier and applies canonical selection in feed, favorites, and subscriptions.
- `src/pages/BlogIndividualPost/BlogIndividualPost.tsx` resolves canonical author before fetching BLOG_POST JSON when wiki mode is enabled.
## UI
- Blog Settings: toggle, Name pickers for whitelist/blacklist.
- Implemented in `Edit Blog` modal (checkbox + comma-separated Name inputs).
- Also available in `Create Blog` modal so new blogs can enable wiki from the start.
- Post page: “Latest by Name on Date” subheader if revision not by Owner.
- Edit: shown only if canEdit true.
- Blog list: use resolver to show canonical per lineage.
- Global feed, Subscriptions, and Favorites use a lightweight cache of per-blog settings to canonicalize duplicates by identifier.
- Header blog switcher seeds blog context to ensure the posts list refreshes immediately on change.
## Backward Compatibility
- Missing fields → defaults (wikiEnabled=false, lists empty, origin=id, lineage=blogId, updatedAt=QDN ts).
## Performance Notes
- Settings Resolution: reads wiki settings from BLOG resource metadata when available; otherwise fetches BLOG JSON as fallback.
- Prefetch: for each page of results, settings for blogs with duplicate identifiers are prefetched in parallel (singletons skip resolution).
- Canonicalization happens only when necessary; otherwise owner or newest item is used.

44
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "q-blog",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "q-blog",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
@@ -45,6 +45,8 @@
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15",
"@types/node": "^24.3.0",
"@types/react": "^18.3.23",
"@types/react-copy-to-clipboard": "^5.0.4",
@@ -7259,13 +7261,13 @@
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"version": "0.30.18",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz",
"integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
@@ -10563,21 +10565,6 @@
}
}
},
"node_modules/vite-node/node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
@@ -11240,21 +11227,6 @@
}
}
},
"node_modules/vitest/node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "q-blog",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -57,6 +57,8 @@
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15",
"@types/node": "^24.3.0",
"@types/react": "^18.3.23",
"@types/react-copy-to-clipboard": "^5.0.4",

View File

@@ -31,6 +31,9 @@ interface MyModalProps {
description: string,
category: string,
tags: string[],
wikiEnabled: boolean,
editorWhitelist: string[],
editorBlacklist: string[],
) => Promise<void>;
currentBlog: any;
}
@@ -52,23 +55,37 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish, currentBlog
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(null);
const [inputValue, setInputValue] = useState<string>('');
const [chips, setChips] = useState<string[]>([]);
const [wikiEnabled, setWikiEnabled] = useState<boolean>(false);
const [whitelist, setWhitelist] = useState<string>('');
const [blacklist, setBlacklist] = useState<string>('');
const [options, setOptions] = useState<SelectOption[]>([]);
React.useEffect(() => {
if (currentBlog) {
setTitle(currentBlog?.title || '');
setDescription(currentBlog?.description || '');
const findCategory = options.find((option) => option.id === currentBlog?.category);
if (!findCategory) return;
setSelectedOption(findCategory);
if (!currentBlog?.tags || !Array.isArray(currentBlog.tags)) return;
setChips(currentBlog.tags);
}
if (!currentBlog) return;
setTitle(currentBlog?.title || '');
setDescription(currentBlog?.description || '');
// Always set wiki fields regardless of category availability
setWikiEnabled(!!currentBlog?.wikiEnabled);
setWhitelist((currentBlog?.editorWhitelist || []).join(', '));
setBlacklist((currentBlog?.editorBlacklist || []).join(', '));
// Category and tags are optional; handle them if present
const findCategory = options.find((option) => option.id === currentBlog?.category);
if (findCategory) setSelectedOption(findCategory);
if (Array.isArray(currentBlog?.tags)) setChips(currentBlog.tags);
}, [currentBlog, options]);
const handlePublish = async (): Promise<void> => {
try {
await onPublish(title, description, selectedOption?.id || '', chips);
const wl = whitelist
.split(',')
.map((n) => n.trim())
.filter(Boolean);
const bl = blacklist
.split(',')
.map((n) => n.trim())
.filter(Boolean);
await onPublish(title, description, selectedOption?.id || '', chips, wikiEnabled, wl, bl);
handleClose();
} catch (error: any) {
setErrorMessage(error.message);
@@ -192,6 +209,30 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish, currentBlog
</FormControl>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<input
id="wiki-enabled"
type="checkbox"
checked={wikiEnabled}
onChange={(e) => setWikiEnabled(e.target.checked)}
/>
<label htmlFor="wiki-enabled">Enable Wiki Mode</label>
</Box>
<TextField
label="Editor Whitelist (comma-separated Names)"
value={whitelist}
onChange={(e) => setWhitelist(e.target.value)}
fullWidth
/>
<TextField
label="Editor Blacklist (comma-separated Names)"
value={blacklist}
onChange={(e) => setBlacklist(e.target.value)}
fullWidth
/>
</Box>
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<TextField

View File

@@ -32,6 +32,9 @@ interface MyModalProps {
category: string,
tags: string[],
blogIdentifier: string,
wikiEnabled: boolean,
editorWhitelist: string[],
editorBlacklist: string[],
) => Promise<void>;
username: string;
}
@@ -55,9 +58,29 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish, username })
const [chips, setChips] = useState<string[]>([]);
const [blogIdentifier, setBlogIdentifier] = useState(username || '');
const [options, setOptions] = useState<SelectOption[]>([]);
const [wikiEnabled, setWikiEnabled] = useState<boolean>(false);
const [whitelist, setWhitelist] = useState<string>('');
const [blacklist, setBlacklist] = useState<string>('');
const handlePublish = async (): Promise<void> => {
try {
await onPublish(title, description, selectedOption?.id || '', chips, blogIdentifier);
const wl = whitelist
.split(',')
.map((n) => n.trim())
.filter(Boolean);
const bl = blacklist
.split(',')
.map((n) => n.trim())
.filter(Boolean);
await onPublish(
title,
description,
selectedOption?.id || '',
chips,
blogIdentifier,
wikiEnabled,
wl,
bl,
);
handleClose();
} catch (error: any) {
setErrorMessage(error.message);
@@ -222,6 +245,29 @@ const MyModal: React.FC<MyModalProps> = ({ open, onClose, onPublish, username })
</Select>
</FormControl>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<input
id="wiki-enabled-create"
type="checkbox"
checked={wikiEnabled}
onChange={(e) => setWikiEnabled(e.target.checked)}
/>
<label htmlFor="wiki-enabled-create">Enable Wiki Mode</label>
</Box>
<TextField
label="Editor Whitelist (comma-separated Names)"
value={whitelist}
onChange={(e) => setWhitelist(e.target.value)}
fullWidth
/>
<TextField
label="Editor Blacklist (comma-separated Names)"
value={blacklist}
onChange={(e) => setBlacklist(e.target.value)}
fullWidth
/>
</Box>
<FormControl fullWidth sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<TextField

4
src/global.d.ts vendored
View File

@@ -26,6 +26,10 @@ interface QortalRequestOptions {
tag3?: string;
tag4?: string;
tag5?: string;
// Wiki mode metadata fields for BLOG publish/update
wikiEnabled?: boolean;
editorWhitelist?: string[];
editorBlacklist?: string[];
coin?: string;
destinationAddress?: string;
amount?: number;

View File

@@ -14,6 +14,8 @@ import {
import { setCurrentBlog, setIsLoadingGlobal } from '../state/features/globalSlice';
import { RootState } from '../state/store';
import { fetchAndEvaluatePosts } from '../utils/fetchPosts';
import { selectCanonical, BlogSettings } from '../utils/wiki';
import { getCachedBlogSettings } from '../utils/wikiSettingsCache';
export const useFetchPosts = () => {
const dispatch = useDispatch();
@@ -100,9 +102,65 @@ export const useFetchPosts = () => {
willFetchAll = false;
fetchAll = responseData.slice(0, findPost);
}
// Canonicalize by identifier using per-blog settings cache
const grouped = new Map<string, any[]>();
for (const it of fetchAll) {
const id = it.identifier;
const arr = grouped.get(id) || [];
arr.push(it);
grouped.set(id, arr);
}
const selectedItems: any[] = [];
const entries = Array.from(grouped.entries());
// Preload settings for blog ids that have duplicate authors
const toPrefetch = Array.from(
new Set(
entries
.filter(([_, arr]) => arr.length > 1)
.map(([id]) => id.split('-post-')[0] as string),
),
);
const preloaded = await Promise.all(
toPrefetch.map((blogFull) =>
getCachedBlogSettings(blogFull).then((v) => [blogFull, v] as const),
),
);
const settingsMap = new Map<string, { ownerName: string; settings: BlogSettings }>(preloaded);
const structureData = fetchAll.map((post: any): BlogPost => {
return {
for (const [id, arr] of entries) {
const [blogFull] = id.split('-post-');
if (arr.length === 1) {
selectedItems.push(arr[0]);
continue;
}
const preload = settingsMap.get(blogFull) || (await getCachedBlogSettings(blogFull));
const ownerName = (preload as any).ownerName as string;
const settings = (preload as any).settings as BlogSettings;
if (settings?.wikiEnabled) {
const revisions = arr.map((it: any) => ({
id: it.identifier,
originPostId: it.identifier,
lineageBlogId: blogFull,
authorName: it.name,
updatedAt: it.updated,
qdnUpdated: it.updated,
published: true,
}));
const chosen = selectCanonical(revisions, settings as BlogSettings, ownerName, {
expectedLineageBlogId: blogFull,
});
const item = chosen
? arr.find((it: any) => it.name === chosen.authorName) || arr[0]
: arr[0];
selectedItems.push(item);
} else {
const ownerItem = arr.find((it: any) => it.name === ownerName);
if (ownerItem) selectedItems.push(ownerItem);
else selectedItems.push(arr.sort((a: any, b: any) => b.updated - a.updated)[0]);
}
}
const structureData = selectedItems.map(
(post: any): BlogPost => ({
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
@@ -113,8 +171,8 @@ export const useFetchPosts = () => {
user: post.name,
postImage: '',
id: post.identifier,
};
});
}),
);
if (!willFetchAll) {
dispatch(upsertPostsBeginning(structureData));
}
@@ -149,8 +207,64 @@ export const useFetchPosts = () => {
},
});
const responseData = await response.json();
const structureData = responseData.map((post: any): BlogPost => {
return {
// Canonicalize by identifier using per-blog settings cache
const grouped = new Map<string, any[]>();
for (const it of responseData) {
const id = it.identifier;
const arr = grouped.get(id) || [];
arr.push(it);
grouped.set(id, arr);
}
const selectedItems: any[] = [];
const entries = Array.from(grouped.entries());
const toPrefetch = Array.from(
new Set(
entries
.filter(([_, arr]) => arr.length > 1)
.map(([id]) => id.split('-post-')[0] as string),
),
);
const preloaded = await Promise.all(
toPrefetch.map((blogFull) =>
getCachedBlogSettings(blogFull).then((v) => [blogFull, v] as const),
),
);
const settingsMap = new Map<string, { ownerName: string; settings: BlogSettings }>(preloaded);
for (const [id, arr] of entries) {
const [blogFull] = id.split('-post-');
if (arr.length === 1) {
selectedItems.push(arr[0]);
continue;
}
const preload = settingsMap.get(blogFull) || (await getCachedBlogSettings(blogFull));
const ownerName = (preload as any).ownerName as string;
const settings = (preload as any).settings as BlogSettings;
if (settings?.wikiEnabled) {
const revisions = arr.map((it: any) => ({
id: it.identifier,
originPostId: it.identifier,
lineageBlogId: blogFull,
authorName: it.name,
updatedAt: it.updated,
qdnUpdated: it.updated,
published: true,
}));
const chosen = selectCanonical(revisions, settings as BlogSettings, ownerName, {
expectedLineageBlogId: blogFull,
});
const item = chosen
? arr.find((it: any) => it.name === chosen.authorName) || arr[0]
: arr[0];
selectedItems.push(item);
} else {
const ownerItem = arr.find((it: any) => it.name === ownerName);
if (ownerItem) selectedItems.push(ownerItem);
else selectedItems.push(arr.sort((a: any, b: any) => b.updated - a.updated)[0]);
}
}
const structureData = selectedItems.map(
(post: any): BlogPost => ({
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
@@ -161,8 +275,8 @@ export const useFetchPosts = () => {
user: post.name,
postImage: '',
id: post.identifier,
};
});
}),
);
dispatch(upsertPosts(structureData));
for (const content of structureData) {
@@ -237,8 +351,66 @@ export const useFetchPosts = () => {
},
});
const responseData = await response.json();
const structureData = responseData.map((post: any): BlogPost => {
return {
// Canonicalize duplicates by identifier using settings cache
const grouped = new Map<string, any[]>();
for (const it of responseData) {
const id = it.identifier;
const arr = grouped.get(id) || [];
arr.push(it);
grouped.set(id, arr);
}
const selectedItems: any[] = [];
const entries = Array.from(grouped.entries());
const toPrefetch = Array.from(
new Set(
entries
.filter(([_, arr]) => arr.length > 1)
.map(([id]) => id.split('-post-')[0] as string),
),
);
const preloaded = await Promise.all(
toPrefetch.map((blogFull) =>
getCachedBlogSettings(blogFull).then((v) => [blogFull, v] as const),
),
);
const settingsMap = new Map<string, { ownerName: string; settings: BlogSettings }>(
preloaded,
);
for (const [id, arr] of entries) {
const [blogFull] = id.split('-post-');
if (arr.length === 1) {
selectedItems.push(arr[0]);
continue;
}
const preload = settingsMap.get(blogFull) || (await getCachedBlogSettings(blogFull));
const ownerName = (preload as any).ownerName as string;
const settings = (preload as any).settings as BlogSettings;
if (settings?.wikiEnabled) {
const revisions = arr.map((it: any) => ({
id: it.identifier,
originPostId: it.identifier,
lineageBlogId: blogFull,
authorName: it.name,
updatedAt: it.updated,
qdnUpdated: it.updated,
published: true,
}));
const chosen = selectCanonical(revisions, settings as BlogSettings, ownerName, {
expectedLineageBlogId: blogFull,
});
const item = chosen
? arr.find((it: any) => it.name === chosen.authorName) || arr[0]
: arr[0];
selectedItems.push(item);
} else {
const ownerItem = arr.find((it: any) => it.name === ownerName);
if (ownerItem) selectedItems.push(ownerItem);
else selectedItems.push(arr.sort((a: any, b: any) => b.updated - a.updated)[0]);
}
}
const structureData = selectedItems.map(
(post: any): BlogPost => ({
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
@@ -248,8 +420,8 @@ export const useFetchPosts = () => {
user: post.name,
postImage: '',
id: post.identifier,
};
});
}),
);
dispatch(upsertSubscriptionPosts(structureData));
for (const content of structureData) {
@@ -275,22 +447,8 @@ export const useFetchPosts = () => {
let favs = [];
for (const item of favSlice) {
try {
// await qortalRequest({
// action: "SEARCH_QDN_RESOURCES",
// service: "THUMBNAIL",
// query: "search query goes here", // Optional - searches both "identifier" and "name" fields
// identifier: "search query goes here", // Optional - searches only the "identifier" field
// name: "search query goes here", // Optional - searches only the "name" field
// prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
// default: false, // Optional - if true, only resources without identifiers are returned
// includeStatus: false, // Optional - will take time to respond, so only request if necessary
// includeMetadata: false, // Optional - will take time to respond, so only request if necessary
// limit: 100,
// offset: 0,
// reverse: true
// });
//TODO - NAME SHOULD BE EXACT
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${item.id}&exactmatchnames=true&name=${item.user}&limit=20&includemetadata=true&reverse=true&excludeblocked=true`;
// Fetch across Names by identifier and select canonical per wiki settings
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${item.id}&limit=50&includemetadata=true&reverse=true&excludeblocked=true`;
const response = await fetch(url, {
method: 'GET',
headers: {
@@ -298,9 +456,30 @@ export const useFetchPosts = () => {
},
});
const data = await response.json();
//
if (data.length > 0) {
favs.push(data[0]);
if (Array.isArray(data) && data.length > 0) {
const [blogFull] = (item.id as string).split('-post-');
const { ownerName, settings } = await getCachedBlogSettings(blogFull);
if (settings?.wikiEnabled) {
const revisions = data.map((it: any) => ({
id: it.identifier,
originPostId: it.identifier,
lineageBlogId: blogFull,
authorName: it.name,
updatedAt: it.updated,
qdnUpdated: it.updated,
published: true,
}));
const chosen = selectCanonical(revisions, settings as BlogSettings, ownerName, {
expectedLineageBlogId: blogFull,
});
const itemChosen = chosen
? data.find((it: any) => it.name === chosen.authorName) || data[0]
: data[0];
favs.push(itemChosen);
} else {
const ownerItem = data.find((it: any) => it.name === ownerName);
favs.push(ownerItem || data.sort((a: any, b: any) => b.updated - a.updated)[0]);
}
}
} catch (error) {}
}

5
src/index.d.ts vendored
View File

@@ -2,8 +2,3 @@ declare module 'webworker:getBlogWorker' {
const value: new () => Worker;
export default value;
}
declare module 'webworker:decodeBase64' {
const value: new () => Worker;
export default value;
}

View File

@@ -32,12 +32,14 @@ import { ReusableModal } from '../../components/modals/ReusableModal';
import AudioElement from '../../components/AudioElement';
import ErrorBoundary from '../../components/common/ErrorBoundary';
import { CommentSection } from '../../components/common/Comments/CommentSection';
import { canEdit, BlogSettings, selectCanonical } from '../../utils/wiki';
import { Tipping } from '../../components/common/Tipping/Tipping';
import FileElement from '../../components/FileElement';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { setNotification } from '../../state/features/notificationsSlice';
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource';
import { PollWidget } from '../../components/common/PollWidget';
import { formatDate } from '../../utils/time';
const ResponsiveGridLayout = WidthProvider(Responsive);
const initialMinHeight = 2; // Define an initial minimum height for grid items
@@ -90,7 +92,7 @@ export const BlogIndividualPost = () => {
return addPrefix(blog);
}, [blog]);
const { user: userState } = useSelector((state: RootState) => state.auth);
const { audios, audioPostId } = useSelector((state: RootState) => state.global);
const { audios, audioPostId, visitingBlog } = useSelector((state: RootState) => state.global);
const [avatarUrl, setAvatarUrl] = React.useState<string>('');
const dispatch = useDispatch();
@@ -108,6 +110,8 @@ export const BlogIndividualPost = () => {
// saveLayoutsToLocalStorage(layoutss)
};
const [blogContent, setBlogContent] = React.useState<BlogContent | null>(null);
const [canonicalAuthor, setCanonicalAuthor] = React.useState<string>('');
const [canonicalUpdated, setCanonicalUpdated] = React.useState<number | null>(null);
const [isOpenSwitchPlaylistModal, setisOpenSwitchPlaylistModal] = useState<boolean>(false);
const tempSaveAudio = useRef<any>(null);
const saveAudio = React.useRef<any>(null);
@@ -125,7 +129,58 @@ export const BlogIndividualPost = () => {
dispatch(setIsLoadingGlobal(true));
const formBlogId = addPrefix(blog);
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId);
const url = `/arbitrary/BLOG_POST/${user}/${formPostId}`;
// Resolve canonical across names if wiki is enabled
const settings: BlogSettings = {
wikiEnabled: (visitingBlog as any)?.wikiEnabled ?? false,
editorWhitelist: (visitingBlog as any)?.editorWhitelist || [],
editorBlacklist: (visitingBlog as any)?.editorBlacklist || [],
};
let contentName = user || '';
if (settings.wikiEnabled) {
try {
const searchUrl = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&identifier=${encodeURIComponent(
formPostId,
)}&limit=50&includemetadata=true&reverse=true&excludeblocked=true`;
const searchResp = await fetch(searchUrl, {
headers: { 'Content-Type': 'application/json' },
});
const items = (await searchResp.json()) || [];
const revisions = items.map((it: any) => ({
id: it.identifier,
originPostId: it.identifier, // grouping by id for v1
lineageBlogId: formBlogId,
authorName: it.name,
updatedAt: it.updated,
qdnUpdated: it.updated,
published: true,
}));
const selected = selectCanonical(
revisions,
settings,
(visitingBlog as any)?.name || user || '',
{
expectedLineageBlogId: formBlogId,
},
);
if (selected && selected.authorName) {
contentName = selected.authorName;
setCanonicalAuthor(selected.authorName);
const tsNum =
typeof (selected as any).updatedAt === 'number'
? (selected as any).updatedAt
: typeof (selected as any).qdnUpdated === 'number'
? (selected as any).qdnUpdated
: null;
setCanonicalUpdated(tsNum);
} else {
setCanonicalAuthor(user || '');
setCanonicalUpdated(null);
}
} catch {}
}
const url = `/arbitrary/BLOG_POST/${contentName}/${formPostId}`;
const response = await fetch(url, {
method: 'GET',
headers: {
@@ -168,10 +223,10 @@ export const BlogIndividualPost = () => {
} finally {
dispatch(setIsLoadingGlobal(false));
}
}, [user, postId, blog]);
}, [user, postId, blog, visitingBlog]);
React.useEffect(() => {
getBlogPost();
}, [postId]);
}, [postId, visitingBlog]);
const switchPlayList = () => {
const filteredAudios = (blogContent?.postContent || []).filter(
@@ -293,7 +348,16 @@ export const BlogIndividualPost = () => {
paddingBottom: '50px',
}}
>
{user === userState?.name && (
{(() => {
const viewer = userState?.name;
const owner = (visitingBlog as any)?.name || user || '';
const settings: BlogSettings = {
wikiEnabled: (visitingBlog as any)?.wikiEnabled ?? false,
editorWhitelist: (visitingBlog as any)?.editorWhitelist || [],
editorBlacklist: (visitingBlog as any)?.editorBlacklist || [],
};
return canEdit(viewer, settings, owner);
})() && (
<Button
sx={{ backgroundColor: theme.palette.secondary.main }}
onClick={() => {
@@ -358,6 +422,20 @@ export const BlogIndividualPost = () => {
>
{blogContent?.title}
</Typography>
{/* Attribution when wiki shows a non-owner revision */}
{(() => {
const owner = (visitingBlog as any)?.name || user || '';
const author = canonicalAuthor || user || '';
if (owner && author && owner !== author) {
return (
<Typography variant="subtitle2" color="textSecondary" sx={{ ml: 1 }}>
Latest by {author}
{canonicalUpdated ? ` · ${formatDate(canonicalUpdated)}` : ''}
</Typography>
);
}
return null;
})()}
<Tooltip title={`Copy post link`} arrow>
<Box
sx={{

View File

@@ -18,6 +18,7 @@ import LazyLoad from '../../components/common/LazyLoad';
import { addPrefix, removePrefix } from '../../utils/blogIdformats';
import Masonry from 'react-masonry-css';
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource';
import { canEdit, BlogSettings, selectCanonical } from '../../utils/wiki';
const breakpointColumnsObj = {
default: 5,
@@ -31,7 +32,7 @@ export const BlogIndividualProfile = () => {
const navigate = useNavigate();
const theme = useTheme();
const { user } = useSelector((state: RootState) => state.auth);
const { currentBlog } = useSelector((state: RootState) => state.global);
const { currentBlog, visitingBlog } = useSelector((state: RootState) => state.global);
const subscriptions = useSelector((state: RootState) => state.blog.subscriptions);
const { blog: blogShortVersion, user: username } = useParams();
@@ -61,8 +62,11 @@ export const BlogIndividualProfile = () => {
try {
dispatch(setIsLoadingGlobal(true));
const offset = blogPosts.length;
//TODO - NAME SHOULD BE EXACT
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=${blog}-post-&limit=20&exactmatchnames=true&name=${name}&includemetadata=true&offset=${offset}&reverse=true`;
const wikiEnabled = !!((userBlog as any)?.wikiEnabled ?? (visitingBlog as any)?.wikiEnabled);
const baseUrl = wikiEnabled
? `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=${blog}-post-&limit=20&includemetadata=true&offset=${offset}&reverse=true&excludeblocked=true`
: `/arbitrary/resources/search?mode=ALL&service=BLOG_POST&query=${blog}-post-&limit=20&exactmatchnames=true&name=${name}&includemetadata=true&offset=${offset}&reverse=true`;
const url = baseUrl;
const response = await fetch(url, {
method: 'GET',
headers: {
@@ -71,28 +75,77 @@ export const BlogIndividualProfile = () => {
});
const responseData = await response.json();
const structureData = responseData.map((post: any): BlogPost => {
return {
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: '',
user: post.name,
postImage: '',
id: post.identifier,
let structureData: BlogPost[] = [];
if (!wikiEnabled) {
structureData = responseData.map(
(post: any): BlogPost => ({
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: '',
user: post.name,
postImage: '',
id: post.identifier,
}),
);
} else {
// Group by identifier (origin fallback = id) and select canonical using wiki settings
const settings = {
wikiEnabled: true,
editorWhitelist:
(userBlog as any)?.editorWhitelist || (visitingBlog as any)?.editorWhitelist || [],
editorBlacklist:
(userBlog as any)?.editorBlacklist || (visitingBlog as any)?.editorBlacklist || [],
};
});
setBlogPosts(structureData);
const ownerName = username || '';
const groups = new Map<string, any[]>();
for (const it of responseData) {
const id = it.identifier;
const arr = groups.get(id) || [];
arr.push(it);
groups.set(id, arr);
}
const selected: any[] = [];
groups.forEach((arr, id) => {
const revisions = arr.map((it: any) => ({
id: it.identifier,
originPostId: it.identifier,
lineageBlogId: blog,
authorName: it.name,
updatedAt: it.updated,
qdnUpdated: it.updated,
published: true,
}));
const c = selectCanonical(revisions, settings as any, ownerName, {
expectedLineageBlogId: blog,
});
if (!c) return;
const chosen = arr.find((it: any) => it.name === c.authorName) || arr[0];
selected.push(chosen);
});
structureData = selected.map(
(post: any): BlogPost => ({
title: post?.metadata?.title,
category: post?.metadata?.category,
categoryName: post?.metadata?.categoryName,
tags: post?.metadata?.tags || [],
description: post?.metadata?.description,
createdAt: '',
user: post.name,
postImage: '',
id: post.identifier,
}),
);
}
// Merge into local state list
const copiedBlogPosts: BlogPost[] = [...blogPosts];
structureData.forEach((post: BlogPost) => {
const index = blogPosts.findIndex((p) => p.id === post.id);
if (index !== -1) {
copiedBlogPosts[index] = post;
} else {
copiedBlogPosts.push(post);
}
const index = copiedBlogPosts.findIndex((p) => p.id === post.id);
if (index !== -1) copiedBlogPosts[index] = post;
else copiedBlogPosts.push(post);
});
setBlogPosts(copiedBlogPosts);
@@ -141,6 +194,19 @@ export const BlogIndividualProfile = () => {
React.useEffect(() => {
getBlog();
}, [username, blog]);
React.useEffect(() => {
// Load first page of posts once blog context is known
if (username && blog) {
getBlogPosts();
}
}, [username, blog]);
// Refresh posts when wiki settings toggle changes (e.g., after loading BLOG JSON or switching blogs)
React.useEffect(() => {
if (!username || !blog) return;
setBlogPosts([]);
getBlogPosts();
}, [username, blog, userBlog?.wikiEnabled, visitingBlog?.wikiEnabled]);
const getPosts = React.useCallback(async () => {
await getBlogPosts();
}, [getBlogPosts]);
@@ -326,7 +392,23 @@ export const BlogIndividualProfile = () => {
fullWidth
/>
</ContextMenuResource>
{blogPost.user === user?.name && (
{(() => {
const viewer = user?.name;
const owner = username || '';
const settings: BlogSettings = {
wikiEnabled:
(userBlog as any)?.wikiEnabled ?? (visitingBlog as any)?.wikiEnabled ?? false,
editorWhitelist:
(userBlog as any)?.editorWhitelist ||
(visitingBlog as any)?.editorWhitelist ||
[],
editorBlacklist:
(userBlog as any)?.editorBlacklist ||
(visitingBlog as any)?.editorBlacklist ||
[],
};
return canEdit(viewer, settings, owner);
})() && (
<EditIcon
className="edit-btn"
sx={{
@@ -393,7 +475,23 @@ export const BlogIndividualProfile = () => {
tags={blogPost?.tags}
/>
</ContextMenuResource>
{blogPost.user === user?.name && (
{(() => {
const viewer = user?.name;
const owner = username || '';
const settings: BlogSettings = {
wikiEnabled:
(userBlog as any)?.wikiEnabled ?? (visitingBlog as any)?.wikiEnabled ?? false,
editorWhitelist:
(userBlog as any)?.editorWhitelist ||
(visitingBlog as any)?.editorWhitelist ||
[],
editorBlacklist:
(userBlog as any)?.editorBlacklist ||
(visitingBlog as any)?.editorBlacklist ||
[],
};
return canEdit(viewer, settings, owner);
})() && (
<EditIcon
className="edit-btn"
sx={{

View File

@@ -83,20 +83,6 @@ const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
const subscriptions = useSelector((state: RootState) => state.blog.subscriptions);
const username = useSelector((state: RootState) => state.auth?.user?.name);
function extractTextFromSlate(nodes: any) {
if (!Array.isArray(nodes)) return '';
let text = '';
for (const node of nodes) {
if (node.text) {
text += node.text;
} else if (node.children) {
text += extractTextFromSlate(node.children);
}
}
return text;
}
const getAvatar = React.useCallback(async () => {
try {
let url = await qortalRequest({

View File

@@ -50,6 +50,9 @@ export const UserBlogs: React.FC = () => {
blogImage: details?.blogImage || '',
category: details?.category,
tags: details?.tags || [],
wikiEnabled: (details as any)?.wikiEnabled ?? false,
editorWhitelist: (details as any)?.editorWhitelist || [],
editorBlacklist: (details as any)?.editorBlacklist || [],
}),
);
dispatch(toggleEditBlogModal(true));

View File

@@ -14,6 +14,9 @@ interface GlobalState {
category?: string;
tags?: string[];
navbarConfig?: any;
wikiEnabled?: boolean;
editorWhitelist?: string[];
editorBlacklist?: string[];
} | null;
visitingBlog: {
createdAt: number;
@@ -25,6 +28,9 @@ interface GlobalState {
tags?: string[];
navbarConfig?: any;
name?: string;
wikiEnabled?: boolean;
editorWhitelist?: string[];
editorBlacklist?: string[];
} | null;
audios: any[] | null;
currAudio: any;

View File

@@ -40,6 +40,10 @@ export interface BlogDetails {
createdAt: number;
category?: string;
tags?: string[];
// Wiki settings (optional; missing implies defaults)
wikiEnabled?: boolean;
editorWhitelist?: string[];
editorBlacklist?: string[];
}
// Fetch a specific blog JSON payload

135
src/utils/wiki.ts Normal file
View File

@@ -0,0 +1,135 @@
export interface BlogSettings {
wikiEnabled?: boolean; // missing = false
editorWhitelist?: string[]; // missing or empty = global allow
editorBlacklist?: string[]; // missing = none
}
export interface RevisionMeta {
id: string; // full BLOG_POST identifier
originPostId?: string; // first ancestor; fallback to id
parentPostId?: string;
lineageBlogId?: string; // owners blog id; fallback provided lineage
authorName: string; // registered name of the author
updatedAt?: string | number; // ISO or epoch ms; fallback to qdnUpdated
qdnUpdated?: number; // numeric QDN updated timestamp if available
published?: boolean; // defaults true if missing
}
function toSet(arr?: string[]): Set<string> {
return new Set((arr || []).filter(Boolean));
}
export function isAuthorized(
authorName: string,
settings: BlogSettings,
ownerName: string,
): boolean {
if (!authorName) return false;
// Owner always allowed
if (ownerName && authorName === ownerName) return true;
const enabled = !!settings?.wikiEnabled;
if (!enabled) return false;
const blacklist = toSet(settings?.editorBlacklist);
const whitelist = toSet(settings?.editorWhitelist);
if (blacklist.has(authorName)) return false; // blacklist precedence
if (whitelist.size > 0) {
return whitelist.has(authorName);
}
// whitelist empty → global allow (minus blacklist already handled)
return true;
}
export function canEdit(
viewerName: string | undefined,
settings: BlogSettings,
ownerName: string,
): boolean {
if (!viewerName) return false;
// Owner always allowed
if (ownerName && viewerName === ownerName) return true;
if (!settings?.wikiEnabled) return false;
const blacklist = toSet(settings?.editorBlacklist);
if (blacklist.has(viewerName)) return false;
const whitelist = toSet(settings?.editorWhitelist);
if (whitelist.size > 0) return whitelist.has(viewerName);
return true; // global allow
}
function getOriginId(r: RevisionMeta): string {
return r.originPostId || r.id;
}
function numTime(value: string | number | undefined, fallback?: number): number {
if (typeof value === 'number' && Number.isFinite(value)) return value as number;
if (typeof value === 'string') {
const t = Date.parse(value);
if (!Number.isNaN(t)) return t;
}
return fallback ?? 0;
}
export interface SelectCanonicalOptions {
expectedLineageBlogId?: string;
}
export function selectCanonical(
revisions: RevisionMeta[],
settings: BlogSettings,
ownerName: string,
opts: SelectCanonicalOptions = {},
): RevisionMeta | null {
if (!Array.isArray(revisions) || revisions.length === 0) return null;
// Group by origin id
const groups = new Map<string, RevisionMeta[]>();
for (const r of revisions) {
const pub = r.published ?? true;
if (!pub) continue;
if (
opts.expectedLineageBlogId &&
r.lineageBlogId &&
opts.expectedLineageBlogId !== r.lineageBlogId
) {
continue;
}
if (!isAuthorized(r.authorName, settings, ownerName)) continue;
const origin = getOriginId(r);
const arr = groups.get(origin) || [];
arr.push(r);
groups.set(origin, arr);
}
// For now, if multiple groups exist, choose the latest canonical across all groups.
// Callers can pre-filter to a single origin when needed.
let best: RevisionMeta | null = null;
const owner = ownerName;
function better(a: RevisionMeta | null, b: RevisionMeta): RevisionMeta {
if (!a) return b;
const aTime = numTime(a.updatedAt, a.qdnUpdated);
const bTime = numTime(b.updatedAt, b.qdnUpdated);
if (bTime !== aTime) return bTime > aTime ? b : a;
const aOwner = a.authorName === owner;
const bOwner = b.authorName === owner;
if (aOwner !== bOwner) return bOwner ? b : a; // owner wins tie
// lowest id wins as last tiebreaker
return b.id < a.id ? b : a;
}
for (const [, arr] of groups) {
// pick best within group
let groupBest: RevisionMeta | null = null;
for (const r of arr) groupBest = better(groupBest, r);
if (groupBest) best = better(best, groupBest);
}
return best;
}

View File

@@ -0,0 +1,66 @@
import type { BlogSettings } from './wiki';
export interface CachedBlogSettings {
ownerName: string;
settings: BlogSettings;
}
const cache = new Map<string, Promise<CachedBlogSettings>>();
export async function getCachedBlogSettings(blogFullId: string): Promise<CachedBlogSettings> {
if (cache.has(blogFullId)) return cache.get(blogFullId)!;
const p = (async () => {
// 1) Find BLOG owner name by identifier
const findUrl = `/arbitrary/resources?service=BLOG&identifier=${encodeURIComponent(
blogFullId,
)}&limit=1&includemetadata=true`;
const findResp = await fetch(findUrl, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const list = (await findResp.json()) || [];
const ownerName = list?.[0]?.name || '';
// Prefer settings from metadata if present to avoid extra fetch
const meta = list?.[0]?.metadata || {};
if (ownerName) {
const metaHasWiki =
Object.prototype.hasOwnProperty.call(meta || {}, 'wikiEnabled') ||
Object.prototype.hasOwnProperty.call(meta || {}, 'editorWhitelist') ||
Object.prototype.hasOwnProperty.call(meta || {}, 'editorBlacklist');
if (metaHasWiki) {
const settings: BlogSettings = {
wikiEnabled: !!meta?.wikiEnabled,
editorWhitelist: Array.isArray(meta?.editorWhitelist) ? meta.editorWhitelist : [],
editorBlacklist: Array.isArray(meta?.editorBlacklist) ? meta.editorBlacklist : [],
};
return { ownerName, settings };
}
}
if (!ownerName) {
// No BLOG entry found; return defaults
return {
ownerName: '',
settings: { wikiEnabled: false, editorWhitelist: [], editorBlacklist: [] },
};
}
// 2) Fetch BLOG JSON for settings
const blogUrl = `/arbitrary/BLOG/${encodeURIComponent(ownerName)}/${encodeURIComponent(
blogFullId,
)}`;
const blogResp = await fetch(blogUrl, { headers: { 'Content-Type': 'application/json' } });
let settings: BlogSettings = { wikiEnabled: false, editorWhitelist: [], editorBlacklist: [] };
if (blogResp.ok) {
const data = await blogResp.json();
settings = {
wikiEnabled: !!data?.wikiEnabled,
editorWhitelist: Array.isArray(data?.editorWhitelist) ? data.editorWhitelist : [],
editorBlacklist: Array.isArray(data?.editorBlacklist) ? data.editorBlacklist : [],
};
}
return { ownerName, settings };
})();
cache.set(blogFullId, p);
return p;
}

View File

@@ -1,62 +0,0 @@
self.addEventListener('message', async (event) => {
//
// const qortalRequest = event.data.qortalRequest
// const name = event.data.name
// const service = event.data.service
// const identifier = event.data.identifier
// const url2 = `/arbitrary/VIDEO/crowetic/q-blog-video-xGR8HP?&encoding=base64`
// const res = await fetch(url2);
// const data = await res.text();
// self.postMessage(data);
const url2 = `/arbitrary/VIDEO/crowetic/q-blog-video-xGR8HP`;
const xhr = new XMLHttpRequest();
xhr.open('GET', url2, true);
xhr.responseType = 'blob';
xhr.onload = () => {
const headers = xhr.getAllResponseHeaders();
const blob = xhr.response;
const url = URL.createObjectURL(blob);
const byteLength = blob.size;
const contentRange = `bytes 0-${byteLength}/${byteLength}`;
const contentType = xhr.getResponseHeader('Content-Type');
self.postMessage(url);
// this.dispatchEvent(new CustomEvent('loaded', { detail: { headers, byteLength, contentRange, contentType } }));
};
xhr.send();
// fetch(url2)
// .then(response => response.blob())
// .then(blob => {
//
// // Create a new Blob with the 'video/mp4' MIME type
// const mp4Blob = new Blob([blob], { type: 'video/mp4' });
//
// // Generate an object URL from the new Blob
// const url = URL.createObjectURL(mp4Blob);
// self.postMessage(url);
// })
// .catch(error => console.error(error));
// const response = await fetch(url2, {
// method: 'GET'
// })
//
// const responseData = await response.json()
//
// const base64Data = responseData
// const decodedData = atob(base64Data);
// const byteNumbers = new Array(decodedData.length);
// for (let i = 0; i < decodedData.length; i++) {
// byteNumbers[i] = decodedData.charCodeAt(i);
// }
// const byteArray = new Uint8Array(byteNumbers);
// const blob = new Blob([byteArray], { type: 'video/mp4' });
// const url = URL.createObjectURL(blob);
// self.postMessage(url);
});

View File

@@ -27,7 +27,7 @@ self.onmessage = async (event) => {
if (!user || !postId) return obj;
try {
const url = `/arbitrary/BLOG_POST / ${user}/${postId}`;
const url = `/arbitrary/BLOG_POST/${user}/${postId}`;
const response = await fetch(url, {
method: 'GET',
headers: {

View File

@@ -13,6 +13,7 @@ import {
setIsLoadingGlobal,
setNotificationCreatorComment,
setNotifications,
setVisitingBlog,
toggleEditBlogModal,
togglePublishBlogModal,
} from '../state/features/globalSlice';
@@ -258,6 +259,9 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
category: string,
tags: string[],
blogIdentifier: string,
wikiEnabled: boolean,
editorWhitelist: string[],
editorBlacklist: string[],
) => {
if (!user || !user.name) throw new Error('Cannot publish: You do not have a Qortal name');
if (!title) throw new Error('A title is required');
@@ -290,6 +294,11 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
description,
blogImage: '',
createdAt: Date.now(),
category,
tags,
wikiEnabled,
editorWhitelist,
editorBlacklist,
};
const blogPostToBase64 = objectToBase64(blogobj);
try {
@@ -302,6 +311,9 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
description,
category,
identifier: identifier,
wikiEnabled,
editorWhitelist,
editorBlacklist,
...formattedTags,
});
// navigate(`/${user.name}/${identifier}`)
@@ -314,8 +326,6 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
const blogfullObj = {
...blogobj,
blogId: identifier,
category,
tags,
};
dispatch(setCurrentBlog(blogfullObj));
@@ -357,7 +367,15 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
);
const editBlog = React.useCallback(
async (title: string, description: string, category: string, tags: string[]) => {
async (
title: string,
description: string,
category: string,
tags: string[],
wikiEnabled: boolean,
editorWhitelist: string[],
editorBlacklist: string[],
) => {
if (!user || !user.name) throw new Error('Cannot update: your Qortal name is not accessible');
if (!currentBlog) throw new Error('Your blog is not available. Refresh and try again.');
@@ -373,6 +391,9 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
...currentBlog,
title,
description,
wikiEnabled,
editorWhitelist,
editorBlacklist,
};
const blogPostToBase64 = objectToBase64(blogobj);
try {
@@ -384,6 +405,9 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
title,
description,
category,
wikiEnabled,
editorWhitelist,
editorBlacklist,
...formattedTags,
identifier: currentBlog.blogId,
});
@@ -474,6 +498,25 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
blogImage: details?.blogImage || '',
category: details?.category,
tags: details?.tags || [],
wikiEnabled: (details as any)?.wikiEnabled ?? false,
editorWhitelist: (details as any)?.editorWhitelist || [],
editorBlacklist: (details as any)?.editorBlacklist || [],
}),
);
// Seed visitingBlog immediately so the blog page uses fresh settings/posts
dispatch(
setVisitingBlog({
createdAt: details?.createdAt || Date.now(),
blogId: blog.blogId,
title: details?.title || '',
description: details?.description || '',
blogImage: details?.blogImage || '',
category: details?.category,
tags: details?.tags || [],
name: user.name,
wikiEnabled: (details as any)?.wikiEnabled ?? false,
editorWhitelist: (details as any)?.editorWhitelist || [],
editorBlacklist: (details as any)?.editorBlacklist || [],
}),
);
}

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { Provider } from 'react-redux';
import { store } from '@/state/store';
import { render, waitFor } from '@testing-library/react';
import { useFetchPosts } from '@/hooks/useFetchPosts';
import { server } from '../msw/server';
import { http, HttpResponse } from 'msw';
import { addFavorites } from '@/state/features/blogSlice';
function Harness({ onReady }: { onReady: (api: ReturnType<typeof useFetchPosts>) => void }) {
const api = useFetchPosts();
React.useEffect(() => {
onReady(api);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}
describe('useFetchPosts favorites — wiki canonical', () => {
it('selects canonical by identifier across names', async () => {
// Favorites local contains the owner entry, but canonical is newer by carol
store.dispatch(addFavorites([{ id: 'q-blog-myblog-post-123', user: 'alice' }])) as any;
server.use(
// Resolve BLOG owner and settings
http.get('/arbitrary/resources', ({ request }) => {
const url = new URL(request.url);
if (
url.searchParams.get('service') === 'BLOG' &&
url.searchParams.get('identifier') === 'q-blog-myblog'
) {
return HttpResponse.json([{ name: 'alice', identifier: 'q-blog-myblog' }]);
}
return HttpResponse.json([]);
}),
http.get('/arbitrary/BLOG/:name/:id', ({ params }) => {
const { name, id } = params as any;
if (name === 'alice' && id === 'q-blog-myblog') {
return HttpResponse.json({ wikiEnabled: true });
}
return HttpResponse.json({}, { status: 404 });
}),
// Search by identifier returns two names; carol is newer (canonical)
http.get('/arbitrary/resources/search', ({ request }) => {
const url = new URL(request.url);
if (
url.searchParams.get('service') === 'BLOG_POST' &&
url.searchParams.get('identifier') === 'q-blog-myblog-post-123'
) {
return HttpResponse.json([
{
name: 'alice',
identifier: 'q-blog-myblog-post-123',
updated: 1000,
metadata: { title: 'Owner' },
},
{
name: 'carol',
identifier: 'q-blog-myblog-post-123',
updated: 2000,
metadata: { title: 'Newer' },
},
]);
}
return HttpResponse.json([]);
}),
http.get('/arbitrary/BLOG_POST/:name/:id', ({ params }) => {
return HttpResponse.json({
title: 'Newer',
createdAt: 1,
postContent: [{ type: 'editor', version: 1, id: 'e1', content: [{ text: 't' }] }],
});
}),
);
let api!: ReturnType<typeof useFetchPosts>;
render(
<Provider store={store}>
<Harness onReady={(a) => (api = a)} />
</Provider>,
);
await api.getBlogPostsFavorites();
await waitFor(() => {
const favs = store.getState().blog.favorites;
expect(favs.length).toBe(1);
expect(favs[0].user).toBe('carol');
});
});
});

View File

@@ -4,4 +4,8 @@ export const handlers = [
http.get('/api/health', () => {
return HttpResponse.json({ ok: true });
}),
// Default fallbacks for QDN endpoints to keep tests isolated
http.get('/arbitrary/resources/search', () => HttpResponse.json([])),
http.get('/arbitrary/resources', () => HttpResponse.json([])),
http.get('/arbitrary/BLOG_COMMENT/:name/:identifier', () => HttpResponse.json({})),
];

View File

@@ -0,0 +1,172 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { Provider } from 'react-redux';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { BlogIndividualPost } from '@/pages/BlogIndividualPost/BlogIndividualPost';
import { store } from '@/state/store';
import { server } from '../msw/server';
import { http, HttpResponse } from 'msw';
import { addUser } from '@/state/features/authSlice';
const mkPost = (title: string) => ({
title,
createdAt: 111,
postContent: [{ type: 'editor', version: 1, id: 'ed1', content: [{ text: 'Hi' }] }],
});
describe('BlogIndividualPost — wiki mode', () => {
it('selects canonical across names and shows attribution', async () => {
// emulate authenticated viewer as carol (not owner)
store.dispatch(addUser({ address: 'addr', publicKey: 'pk', name: 'carol' }));
// Blog with wiki enabled
server.use(
http.get('/arbitrary/BLOG/:user/:blog', ({ params }) =>
HttpResponse.json({
title: 'My Wiki Blog',
createdAt: 1,
blogId: params.blog,
description: 'd',
blogImage: '',
wikiEnabled: true,
editorWhitelist: [],
editorBlacklist: [],
name: params.user,
}),
),
// Search by exact identifier returns multiple names
http.get('/arbitrary/resources/search', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('service') === 'BLOG_POST' && url.searchParams.get('identifier')) {
// two revisions: bob older, carol newer → carol wins
return HttpResponse.json([
{
name: 'bob',
identifier: 'q-blog-myblog-post-123',
updated: 1000,
metadata: { title: 'T' },
},
{
name: 'carol',
identifier: 'q-blog-myblog-post-123',
updated: 2000,
metadata: { title: 'T' },
},
]);
}
return HttpResponse.json([]);
}),
// BLOG_POST fetch for selected author
http.get('/arbitrary/BLOG_POST/:name/:id', ({ params }) => {
const { name, id } = params as any;
if (id?.toString().includes('-post-')) {
return HttpResponse.json(mkPost(`Post from ${name}`));
}
return HttpResponse.json({}, { status: 404 });
}),
);
render(
<Provider store={store}>
<ThemeProvider theme={createTheme()}>
<MemoryRouter initialEntries={[`/alice/myblog/123`]}>
<Routes>
<Route path="/:user/:blog/:postId" element={<BlogIndividualPost />} />
</Routes>
</MemoryRouter>
</ThemeProvider>
</Provider>,
);
// Title renders from selected author
expect(await screen.findByText('Post from carol')).toBeInTheDocument();
// Attribution indicates non-owner author
expect(await screen.findByText(/Latest by carol/)).toBeInTheDocument();
// Edit button visible to viewer (global allow)
expect(await screen.findByText('Edit Post')).toBeInTheDocument();
});
it('hides Edit when viewer is blacklisted or not whitelisted', async () => {
// viewer is carol
store.dispatch(addUser({ address: 'addr', publicKey: 'pk', name: 'carol' }));
// Case 1: blacklist blocks
server.use(
http.get('/arbitrary/BLOG/:user/:blog', ({ params }) =>
HttpResponse.json({
title: 'My Wiki Blog',
createdAt: 1,
blogId: params.blog,
description: 'd',
blogImage: '',
wikiEnabled: true,
editorWhitelist: [],
editorBlacklist: ['carol'],
name: params.user,
}),
),
http.get('/arbitrary/resources/search', () =>
HttpResponse.json([
{
name: 'carol',
identifier: 'q-blog-myblog-post-123',
updated: 2000,
metadata: { title: 'T' },
},
]),
),
http.get(
'/arbitrary/BLOG_POST/:name/:id',
({ params }) => HttpResponse.json(mkPost('Post')), // content
),
);
render(
<Provider store={store}>
<ThemeProvider theme={createTheme()}>
<MemoryRouter initialEntries={[`/alice/myblog/123`]}>
<Routes>
<Route path="/:user/:blog/:postId" element={<BlogIndividualPost />} />
</Routes>
</MemoryRouter>
</ThemeProvider>
</Provider>,
);
expect(await screen.findByText('Post')).toBeInTheDocument();
expect(screen.queryByText('Edit Post')).toBeNull();
// Case 2: whitelist excludes carol (only bob allowed)
server.use(
http.get('/arbitrary/BLOG/:user/:blog', ({ params }) =>
HttpResponse.json({
title: 'My Wiki Blog',
createdAt: 1,
blogId: params.blog,
description: 'd',
blogImage: '',
wikiEnabled: true,
editorWhitelist: ['bob'],
editorBlacklist: [],
name: params.user,
}),
),
);
// Navigate to trigger re-render
// Re-rendering MemoryRouter with same route will call effects again
render(
<Provider store={store}>
<ThemeProvider theme={createTheme()}>
<MemoryRouter initialEntries={[`/alice/myblog/123`]}>
<Routes>
<Route path="/:user/:blog/:postId" element={<BlogIndividualPost />} />
</Routes>
</MemoryRouter>
</ThemeProvider>
</Provider>,
);
expect(await screen.findByText('Post')).toBeInTheDocument();
expect(screen.queryByText('Edit Post')).toBeNull();
});
});

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { Provider } from 'react-redux';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { BlogIndividualProfile } from '@/pages/BlogIndividualProfile/BlogIndividualProfile';
import { store } from '@/state/store';
import { server } from '../msw/server';
import { http, HttpResponse } from 'msw';
const postJson = {
title: 'X',
createdAt: 1,
postContent: [{ type: 'editor', version: 1, id: 'ed1', content: [{ text: 'x' }] }],
};
describe('BlogIndividualProfile — wiki canonical grouping', () => {
it('shows canonical per identifier when wiki enabled', async () => {
server.use(
http.get('/arbitrary/BLOG/:user/:blog', ({ params }) =>
HttpResponse.json({
title: 'My Wiki Blog',
createdAt: 1,
blogId: params.blog,
description: 'd',
blogImage: '',
wikiEnabled: true,
name: params.user,
}),
),
http.get('/arbitrary/resources/search', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('service') === 'BLOG_POST') {
// Two identifiers (A and B), each with two authors; newer wins per identifier
return HttpResponse.json([
{
name: 'bob',
identifier: 'q-blog-myblog-post-1',
updated: 1000,
metadata: { title: 'A' },
},
{
name: 'carol',
identifier: 'q-blog-myblog-post-1',
updated: 2000,
metadata: { title: 'A' },
},
{
name: 'dave',
identifier: 'q-blog-myblog-post-2',
updated: 1000,
metadata: { title: 'B' },
},
{
name: 'erin',
identifier: 'q-blog-myblog-post-2',
updated: 3000,
metadata: { title: 'B' },
},
]);
}
return HttpResponse.json([]);
}),
http.get('/arbitrary/BLOG_POST/:name/:id', ({ params }) => {
const { id } = params as any;
if (id?.toString().includes('-post-')) {
return HttpResponse.json(postJson);
}
return HttpResponse.json({}, { status: 404 });
}),
);
render(
<Provider store={store}>
<ThemeProvider theme={createTheme()}>
<MemoryRouter initialEntries={[`/alice/myblog`]}>
<Routes>
<Route path="/:user/:blog" element={<BlogIndividualProfile />} />
</Routes>
</MemoryRouter>
</ThemeProvider>
</Provider>,
);
// Expect canonical authors (carol for A, erin for B) to render
expect(await screen.findByText('carol')).toBeInTheDocument();
expect(await screen.findByText('erin')).toBeInTheDocument();
});
});

101
tests/utils/wiki.test.ts Normal file
View File

@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { canEdit, isAuthorized, selectCanonical, BlogSettings, RevisionMeta } from '@/utils/wiki';
describe('wiki auth', () => {
const owner = 'alice';
it('owner always allowed (auth + edit)', () => {
const s: BlogSettings = { wikiEnabled: false };
expect(isAuthorized(owner, s, owner)).toBe(true);
expect(canEdit(owner, s, owner)).toBe(true);
});
it('wiki disabled: only owner allowed', () => {
const s: BlogSettings = { wikiEnabled: false };
expect(isAuthorized('bob', s, owner)).toBe(false);
expect(canEdit('bob', s, owner)).toBe(false);
});
it('blacklist blocks even with whitelist', () => {
const s: BlogSettings = {
wikiEnabled: true,
editorWhitelist: ['bob'],
editorBlacklist: ['bob'],
};
expect(isAuthorized('bob', s, owner)).toBe(false);
expect(canEdit('bob', s, owner)).toBe(false);
});
it('whitelist gates when non-empty', () => {
const s: BlogSettings = { wikiEnabled: true, editorWhitelist: ['bob'] };
expect(isAuthorized('bob', s, owner)).toBe(true);
expect(isAuthorized('carol', s, owner)).toBe(false);
});
it('global allow when whitelist empty', () => {
const s: BlogSettings = { wikiEnabled: true };
expect(isAuthorized('bob', s, owner)).toBe(true);
expect(canEdit('carol', s, owner)).toBe(true);
});
});
describe('wiki canonical selection', () => {
const owner = 'alice';
const settings: BlogSettings = { wikiEnabled: true };
const revs: RevisionMeta[] = [
{
id: 'q-blog-a-post-1-1',
originPostId: 'q-blog-a-post-1-0',
lineageBlogId: 'q-blog-a',
authorName: 'bob',
updatedAt: '2025-08-20T10:00:00Z',
published: true,
},
{
id: 'q-blog-a-post-1-2',
originPostId: 'q-blog-a-post-1-0',
lineageBlogId: 'q-blog-a',
authorName: 'carol',
updatedAt: '2025-08-21T09:00:00Z',
published: true,
},
{
id: 'q-blog-a-post-1-3',
originPostId: 'q-blog-a-post-1-0',
lineageBlogId: 'q-blog-a',
authorName: owner,
updatedAt: '2025-08-21T09:00:00Z', // tie with carol; owner should win
published: true,
},
{
id: 'q-blog-a-post-1-4',
originPostId: 'q-blog-a-post-1-0',
lineageBlogId: 'q-blog-a',
authorName: 'dave',
updatedAt: '2025-08-19T00:00:00Z',
published: false, // hidden
},
];
it('picks newest; owner wins tie; filters unpublished', () => {
const sel = selectCanonical(revs, settings, owner, { expectedLineageBlogId: 'q-blog-a' });
expect(sel?.id).toBe('q-blog-a-post-1-3');
});
it('blacklist does not override owner precedence', () => {
const sel = selectCanonical(revs, { wikiEnabled: true, editorBlacklist: [owner] }, owner, {
expectedLineageBlogId: 'q-blog-a',
});
// Owner always allowed even if listed
expect(sel?.id).toBe('q-blog-a-post-1-3');
});
it('respects whitelist', () => {
const sel = selectCanonical(revs, { wikiEnabled: true, editorWhitelist: ['bob'] }, owner, {
expectedLineageBlogId: 'q-blog-a',
});
// only bob + owner allowed; bob newer than bob? owner at same time but owner wins tie → owner
expect(sel?.id).toBe('q-blog-a-post-1-3');
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { server } from '../msw/server';
import { http, HttpResponse } from 'msw';
import { getCachedBlogSettings } from '@/utils/wikiSettingsCache';
describe('wikiSettingsCache', () => {
it('returns owner and settings from QDN', async () => {
server.use(
http.get('/arbitrary/resources', ({ request }) => {
const url = new URL(request.url);
if (
url.searchParams.get('service') === 'BLOG' &&
url.searchParams.get('identifier') === 'q-blog-myblog'
) {
return HttpResponse.json([{ name: 'alice', identifier: 'q-blog-myblog' }]);
}
return HttpResponse.json([]);
}),
http.get('/arbitrary/BLOG/:name/:id', ({ params }) => {
const { name, id } = params as any;
if (name === 'alice' && id === 'q-blog-myblog') {
return HttpResponse.json({
wikiEnabled: true,
editorWhitelist: ['bob'],
editorBlacklist: [],
});
}
return HttpResponse.json({}, { status: 404 });
}),
);
const { ownerName, settings } = await getCachedBlogSettings('q-blog-myblog');
expect(ownerName).toBe('alice');
expect(settings.wikiEnabled).toBe(true);
expect(settings.editorWhitelist).toContain('bob');
});
});