forked from Qortal-Forker/q-blog
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
All checks were successful
CI / build_test (push) Successful in 3m33s
This commit is contained in:
26
docs/DECISIONS/ADR-0006-wiki-mode-multi-editor.md
Normal file
26
docs/DECISIONS/ADR-0006-wiki-mode-multi-editor.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# ADR 0006 — Wiki Mode (Multi‑Editor via Visible Revisions)
|
||||
|
||||
Date: 2025-08-22
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Clone‑on‑edit bug revealed a path to collaborative editing. We want to formalize this as Wiki mode.
|
||||
|
||||
## Decision
|
||||
|
||||
- Add per‑blog wikiEnabled toggle.
|
||||
- Add Name‑based whitelist/blacklist with precedence: blacklist > whitelist > global.
|
||||
- Posts remain immutable; revisions link via originPostId/parentPostId/lineageBlogId.
|
||||
- Canonical revision chosen client‑side by resolver.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Multiple editors may contribute visible revisions.
|
||||
- Owner can revoke visibility instantly by changing lists.
|
||||
- Legacy posts unaffected (wikiEnabled missing = false).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- Server‑side canonical logic (not possible in Qortal).
|
||||
- Per‑revision ACLs (heavier; unnecessary for v1).
|
||||
@@ -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**
|
||||
|
||||
36
docs/RELEASE_NOTES_v0.1.1.md
Normal file
36
docs/RELEASE_NOTES_v0.1.1.md
Normal 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; per‑page 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 fast‑pathed.
|
||||
|
||||
## 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.
|
||||
38
docs/RELEASE_NOTES_v0.2.0.md
Normal file
38
docs/RELEASE_NOTES_v0.2.0.md
Normal file
@@ -0,0 +1,38 @@
|
||||
Q‑Blog 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.
|
||||
- Per‑blog settings cache: Efficiently resolves each blog’s 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 non‑owners if non‑empty.
|
||||
- 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 per‑blog 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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
19
docs/USER_ANNOUNCEMENT_v0.2.0.md
Normal file
19
docs/USER_ANNOUNCEMENT_v0.2.0.md
Normal file
@@ -0,0 +1,19 @@
|
||||
Q‑Blog v0.2.0 — Wiki Mode is here
|
||||
|
||||
We’ve shipped a smarter, collaborative blogging experience. When multiple people post updates to the same article, Q‑Blog now shows a single, “canonical” version based on the blog owner’s wiki settings.
|
||||
|
||||
What’s new
|
||||
|
||||
- Canonical posts: If several authors publish a revision of the same post, Q‑Blog picks one to display. The owner’s settings define who can contribute. The most up‑to‑date allowed revision wins; the owner’s 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 Q‑Blog!
|
||||
37
docs/features/FEATURE_WIKI_MODE_OVERVIEW.md
Normal file
37
docs/features/FEATURE_WIKI_MODE_OVERVIEW.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Wiki Mode / Multi‑Editor — Product Overview
|
||||
|
||||
_Generated 2025-08-22_
|
||||
|
||||
## Summary
|
||||
|
||||
Q‑Blog 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 **client‑side**; Qortal has no server scripting.
|
||||
- Precedence: **blacklist > whitelist > global allow**.
|
||||
- Backward‑compatible: 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 per‑blog 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.
|
||||
- Tie‑breakers: 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.
|
||||
36
docs/features/SPEC_WIKI_RESOLVER.md
Normal file
36
docs/features/SPEC_WIKI_RESOLVER.md
Normal 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 non‑empty → 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
|
||||
66
docs/features/TECH_IMPL_WIKI_MODE.md
Normal file
66
docs/features/TECH_IMPL_WIKI_MODE.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Wiki Mode / Multi‑Editor — 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` (owner’s 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 non‑empty → 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 per‑blog (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
44
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
src/global.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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
5
src/index.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
135
src/utils/wiki.ts
Normal 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; // owner’s 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;
|
||||
}
|
||||
66
src/utils/wikiSettingsCache.ts
Normal file
66
src/utils/wikiSettingsCache.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 || [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
92
tests/hooks/useFetchPosts.favorites.wiki.test.tsx
Normal file
92
tests/hooks/useFetchPosts.favorites.wiki.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({})),
|
||||
];
|
||||
|
||||
172
tests/pages/BlogIndividualPost.wiki.test.tsx
Normal file
172
tests/pages/BlogIndividualPost.wiki.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
90
tests/pages/BlogIndividualProfile.wiki.test.tsx
Normal file
90
tests/pages/BlogIndividualProfile.wiki.test.tsx
Normal 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
101
tests/utils/wiki.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
37
tests/utils/wikiSettingsCache.test.ts
Normal file
37
tests/utils/wikiSettingsCache.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user