Browse Source

implement piper

master
Silberengel 4 weeks ago
parent
commit
7c34c3446f
  1. 3
      package-lock.json
  2. 1
      package.json
  3. 23
      public/changelog.yaml
  4. 7
      src/lib/components/content/FileExplorer.svelte
  5. 73
      src/lib/components/modals/UpdateModal.svelte
  6. 92
      src/lib/services/changelog.ts
  7. 14
      src/lib/services/content/git-repo-fetcher.ts
  8. 137
      src/lib/services/github-api.ts
  9. 83
      src/routes/about/+page.svelte

3
package-lock.json generated

@ -30,6 +30,7 @@
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"idb": "^8.0.0", "idb": "^8.0.0",
"js-yaml": "^4.1.1",
"lucide-svelte": "^0.563.0", "lucide-svelte": "^0.563.0",
"marked": "^11.1.1", "marked": "^11.1.1",
"nostr-tools": "^2.22.1", "nostr-tools": "^2.22.1",
@ -3943,7 +3944,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
@ -6635,7 +6635,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"

1
package.json

@ -44,6 +44,7 @@
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"idb": "^8.0.0", "idb": "^8.0.0",
"js-yaml": "^4.1.1",
"lucide-svelte": "^0.563.0", "lucide-svelte": "^0.563.0",
"marked": "^11.1.1", "marked": "^11.1.1",
"nostr-tools": "^2.22.1", "nostr-tools": "^2.22.1",

23
public/changelog.yaml

@ -0,0 +1,23 @@
versions:
'0.3.1':
- 'Media attachments rendering in all feeds and views'
- 'NIP-92/NIP-94 image tags support'
- 'Blossom support for media attachments'
- 'OP-only view added to feed'
- 'Text to Speech (TTS) support added, for the languages: Czech, German, British English, American English, Spanish, French, Dutch, Polish, Russian, Turkish, and Chinese'
'0.3.0':
- 'Version history modal added to event menu'
- 'Event and npub deletion/reporting added'
- 'User and event reporting added'
- 'Profile settings added'
- 'Finished implementing themes'
- 'Add Edit/Clone of all events'
'0.2.1':
- 'Themes added: Fog, Forum, Socialmedia, and Terminal'
- 'Find page added: search for content by hashtags, users, and content, full-text search'
'0.2.0':
- 'Version management and update system'
- 'About page with product information'
- 'Improved user experience with automatic routing to About page on first visit and after updates'
- 'Enhanced settings page with About button'
- 'Better version tracking and display'

7
src/lib/components/content/FileExplorer.svelte

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { GitFile, GitRepoInfo } from '../../services/content/git-repo-fetcher.js'; import type { GitFile, GitRepoInfo } from '../../services/content/git-repo-fetcher.js';
import { fetchGitHubApi } from '../../services/github-api.js';
// @ts-ignore - highlight.js default export works at runtime // @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css'; import 'highlight.js/styles/vs2015.css';
@ -115,7 +116,11 @@
throw new Error('Unable to determine API endpoint for this repository'); throw new Error('Unable to determine API endpoint for this repository');
} }
const response = await fetch(apiUrl); // Use GitHub API helper for GitHub repos (handles rate limiting and token fallback)
const response = url.includes('github.com')
? await fetchGitHubApi(apiUrl)
: await fetch(apiUrl);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
} }

73
src/lib/components/modals/UpdateModal.svelte

@ -2,6 +2,7 @@
import { prewarmCaches, type PrewarmProgress } from '../../services/cache/cache-prewarmer.js'; import { prewarmCaches, type PrewarmProgress } from '../../services/cache/cache-prewarmer.js';
import { markVersionUpdated, getAppVersion } from '../../services/version-manager.js'; import { markVersionUpdated, getAppVersion } from '../../services/version-manager.js';
import { getDB } from '../../services/cache/indexeddb-store.js'; import { getDB } from '../../services/cache/indexeddb-store.js';
import { getNewestChangelog } from '../../services/changelog.js';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -17,6 +18,7 @@
let showPWAUpdatePrompt = $state(false); let showPWAUpdatePrompt = $state(false);
let appVersion = $state('0.2.0'); let appVersion = $state('0.2.0');
let isPWAInstalled = $state(false); let isPWAInstalled = $state(false);
let newestChangelog = $state<string[]>([]);
let progress = $state<PrewarmProgress>({ let progress = $state<PrewarmProgress>({
step: 'Preparing update...', step: 'Preparing update...',
progress: 0, progress: 0,
@ -26,6 +28,7 @@
onMount(async () => { onMount(async () => {
appVersion = await getAppVersion(); appVersion = await getAppVersion();
newestChangelog = await getNewestChangelog();
// Check if PWA is installed // Check if PWA is installed
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -242,14 +245,27 @@
{#if !updating} {#if !updating}
<div class="update-modal-content"> <div class="update-modal-content">
<p class="update-modal-description"> {#if newestChangelog.length > 0}
This update will: <div class="whats-new-section">
</p> <h3 class="whats-new-title">What's New in Version {appVersion}</h3>
<ul class="update-modal-list"> <ul class="whats-new-list">
<li>Update the database structure</li> {#each newestChangelog as change}
<li>Preload common data for faster performance</li> <li>{change}</li>
<li>Prepare caches for quick access</li> {/each}
</ul> </ul>
</div>
{/if}
<div class="update-process-section">
<p class="update-modal-description">
This update will:
</p>
<ul class="update-modal-list">
<li>Update the database structure</li>
<li>Preload common data for faster performance</li>
<li>Prepare caches for quick access</li>
</ul>
</div>
</div> </div>
<div class="update-modal-actions"> <div class="update-modal-actions">
@ -408,6 +424,47 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.whats-new-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .whats-new-section {
border-bottom-color: var(--fog-dark-border, #475569);
}
.whats-new-title {
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .whats-new-title {
color: var(--fog-dark-text, #f9fafb);
}
.whats-new-list {
margin: 0;
padding-left: 1.5rem;
color: var(--fog-text, #1f2937);
list-style-type: disc;
}
:global(.dark) .whats-new-list {
color: var(--fog-dark-text, #f9fafb);
}
.whats-new-list li {
margin-bottom: 0.5rem;
line-height: 1.5;
}
.update-process-section {
margin-top: 1rem;
}
.update-modal-note { .update-modal-note {
margin: 1rem 0 0 0; margin: 1rem 0 0 0;
font-size: 0.875rem; font-size: 0.875rem;

92
src/lib/services/changelog.ts

@ -0,0 +1,92 @@
/**
* Changelog service - loads and provides access to version changelog
*/
import yaml from 'js-yaml';
export interface ChangelogData {
versions: Record<string, string[]>;
}
let cachedChangelog: ChangelogData | null = null;
/**
* Load changelog from YAML file
*/
export async function loadChangelog(): Promise<ChangelogData> {
if (cachedChangelog) {
return cachedChangelog;
}
try {
const response = await fetch('/changelog.yaml', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Failed to fetch changelog: ${response.statusText}`);
}
const yamlText = await response.text();
const parsed = yaml.load(yamlText) as ChangelogData;
// Validate structure
if (!parsed || !parsed.versions || typeof parsed.versions !== 'object') {
throw new Error('Invalid changelog format');
}
cachedChangelog = parsed;
return cachedChangelog;
} catch (error) {
console.error('Failed to load changelog:', error);
// Return empty changelog on error
return { versions: {} };
}
}
/**
* Get changelog for a specific version
*/
export async function getChangelogForVersion(version: string): Promise<string[]> {
const changelog = await loadChangelog();
return changelog.versions[version] || [];
}
/**
* Get all versions in the changelog, sorted by version number (newest first)
*/
export async function getAllVersions(): Promise<string[]> {
const changelog = await loadChangelog();
const versions = Object.keys(changelog.versions);
// Sort versions in descending order (newest first)
return versions.sort((a, b) => {
const partsA = a.split('.').map(Number);
const partsB = b.split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i] || 0;
const partB = partsB[i] || 0;
if (partA !== partB) {
return partB - partA; // Descending order
}
}
return 0;
});
}
/**
* Get the newest version from the changelog
*/
export async function getNewestVersion(): Promise<string | null> {
const versions = await getAllVersions();
return versions.length > 0 ? versions[0] : null;
}
/**
* Get changelog for the newest version
*/
export async function getNewestChangelog(): Promise<string[]> {
const newestVersion = await getNewestVersion();
if (!newestVersion) {
return [];
}
return getChangelogForVersion(newestVersion);
}

14
src/lib/services/content/git-repo-fetcher.ts

@ -3,6 +3,8 @@
* Supports GitHub, GitLab, Gitea, and other git hosting services * Supports GitHub, GitLab, Gitea, and other git hosting services
*/ */
import { fetchGitHubApi } from '../github-api.js';
export interface GitRepoInfo { export interface GitRepoInfo {
name: string; name: string;
description?: string; description?: string;
@ -87,7 +89,7 @@ function parseGitUrl(url: string): { platform: string; owner: string; repo: stri
*/ */
async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo | null> { async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo | null> {
try { try {
const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`); const repoResponse = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}`);
if (!repoResponse.ok) { if (!repoResponse.ok) {
console.warn(`GitHub API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`); console.warn(`GitHub API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`);
return null; return null;
@ -96,9 +98,9 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo
const defaultBranch = repoData.default_branch || 'main'; const defaultBranch = repoData.default_branch || 'main';
const [branchesResponse, commitsResponse, treeResponse] = await Promise.all([ const [branchesResponse, commitsResponse, treeResponse] = await Promise.all([
fetch(`https://api.github.com/repos/${owner}/${repo}/branches`), fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/branches`),
fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`), fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`),
fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => null) fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => null)
]); ]);
// Check if responses are OK and parse JSON // Check if responses are OK and parse JSON
@ -204,7 +206,7 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) { for (const readmeFile of readmeFiles) {
try { try {
const readmeData = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`).then(r => { const readmeData = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`).then(r => {
if (!r.ok) throw new Error('Not found'); if (!r.ok) throw new Error('Not found');
return r.json(); return r.json();
}); });
@ -242,7 +244,7 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo
// If found in tree, fetch it // If found in tree, fetch it
if (readmePath) { if (readmePath) {
try { try {
const readmeData = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${readmePath}?ref=${defaultBranch}`).then(r => { const readmeData = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/contents/${readmePath}?ref=${defaultBranch}`).then(r => {
if (!r.ok) throw new Error('Not found'); if (!r.ok) throw new Error('Not found');
return r.json(); return r.json();
}); });

137
src/lib/services/github-api.ts

@ -0,0 +1,137 @@
/**
* GitHub API helper with rate limit detection and token fallback
*/
import { sessionManager } from './auth/session-manager.js';
import { loadEncryptedApiKey } from './security/api-key-storage.js';
/**
* Check if a GitHub API response indicates rate limiting
*/
function isRateLimited(response: Response): boolean {
// Check status code
if (response.status === 403 || response.status === 429) {
return true;
}
// Check rate limit headers
const remaining = response.headers.get('X-RateLimit-Remaining');
if (remaining !== null && parseInt(remaining, 10) === 0) {
return true;
}
return false;
}
/**
* Get GitHub token from settings if available
* Returns null if token is not available or cannot be decrypted
*/
async function getGitHubToken(): Promise<string | null> {
try {
const session = sessionManager.getSession();
if (!session) {
// No session, can't decrypt token
return null;
}
// Try to get password from session (for nsec/anonymous methods)
let password: string | undefined = session.password;
// If no password in session, try to get from sessionStorage (for nsec/anonymous)
if (!password && typeof window !== 'undefined') {
try {
const encryptedPassword = sessionStorage.getItem(`aitherboard_password_${session.pubkey}`);
if (encryptedPassword) {
// Decrypt password from sessionStorage
const key = session.pubkey.slice(0, 16);
const encryptedBytes = atob(encryptedPassword);
let decrypted = '';
for (let i = 0; i < encryptedBytes.length; i++) {
const keyChar = key.charCodeAt(i % key.length);
const encChar = encryptedBytes.charCodeAt(i);
decrypted += String.fromCharCode(encChar ^ keyChar);
}
password = decrypted;
}
} catch {
// Failed to decrypt password from sessionStorage
}
}
// If still no password, we can't decrypt the token
if (!password) {
return null;
}
// Try to load the GitHub token
const token = await loadEncryptedApiKey('github.token', password);
return token;
} catch (error) {
console.warn('Failed to get GitHub token:', error);
return null;
}
}
/**
* Make a GitHub API request with automatic token fallback on rate limiting
* @param url - GitHub API URL
* @param options - Fetch options (headers will be merged)
* @returns Response object
*/
export async function fetchGitHubApi(
url: string,
options: RequestInit = {}
): Promise<Response> {
// First attempt without token
let response = await fetch(url, {
...options,
headers: {
'Accept': 'application/vnd.github.v3+json',
...options.headers
}
});
// Check if rate limited
if (isRateLimited(response)) {
console.log('GitHub API rate limit detected, attempting to use saved token...');
// Try to get token from settings
const token = await getGitHubToken();
if (token) {
console.log('Using saved GitHub token for authenticated request');
// Retry with token (use Bearer for compatibility with both classic and fine-grained tokens)
response = await fetch(url, {
...options,
headers: {
'Accept': 'application/vnd.github.v3+json',
'Authorization': `Bearer ${token}`,
...options.headers
}
});
// Check if still rate limited (token might also be exhausted)
if (isRateLimited(response)) {
const resetTime = response.headers.get('X-RateLimit-Reset');
if (resetTime) {
const resetDate = new Date(parseInt(resetTime, 10) * 1000);
console.warn(`GitHub API rate limit still exceeded even with token. Resets at: ${resetDate.toISOString()}`);
} else {
console.warn('GitHub API rate limit exceeded even with token');
}
}
} else {
const resetTime = response.headers.get('X-RateLimit-Reset');
if (resetTime) {
const resetDate = new Date(parseInt(resetTime, 10) * 1000);
console.warn(`GitHub API rate limit exceeded. No token available. Resets at: ${resetDate.toISOString()}`);
} else {
console.warn('GitHub API rate limit exceeded. No token available in settings.');
}
}
}
return response;
}

83
src/routes/about/+page.svelte

@ -4,11 +4,19 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Icon from '../../lib/components/ui/Icon.svelte'; import Icon from '../../lib/components/ui/Icon.svelte';
import { getAppVersion } from '../../lib/services/version-manager.js'; import { getAppVersion } from '../../lib/services/version-manager.js';
import { getAllVersions, loadChangelog } from '../../lib/services/changelog.js';
let appVersion = $state('0.3.1'); let appVersion = $state('0.3.1');
let allVersions = $state<string[]>([]);
let changelog = $state<Record<string, string[]>>({});
let loadingChangelog = $state(true);
onMount(async () => { onMount(async () => {
appVersion = await getAppVersion(); appVersion = await getAppVersion();
allVersions = await getAllVersions();
const changelogData = await loadChangelog();
changelog = changelogData.versions;
loadingChangelog = false;
}); });
function handleBack() { function handleBack() {
@ -18,35 +26,6 @@
goto('/'); goto('/');
} }
} }
// Changelog for current version
const changelog: Record<string, string[]> = {
'0.3.1': [
'Media attachments rendering in all feeds and views',
'NIP-92/NIP-94 image tags support',
'Blossom support for media attachments',
'OP-only view added to feed',
],
'0.3.0': [
'Version history modal added to event menu',
'Event and npub deletion/reporting added',
'User and event reporting added',
'Profile settings added',
'Finished implementing themes',
'Add Edit/Clone of all events'
],
'0.2.1': [
'Themes added: Fog, Forum, Socialmedia, and Terminal',
'Find page added: search for content by hashtags, users, and content, full-text search'
],
'0.2.0': [
'Version management and update system',
'About page with product information',
'Improved user experience with automatic routing to About page on first visit and after updates',
'Enhanced settings page with About button',
'Better version tracking and display'
]
};
</script> </script>
<Header /> <Header />
@ -126,16 +105,27 @@
<!-- Changelog --> <!-- Changelog -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded"> <section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">What's New in Version {appVersion}</h2> <h2 class="section-title">Changelog</h2>
<div class="section-content"> <div class="section-content">
{#if changelog[appVersion]} {#if loadingChangelog}
<ul class="changelog-list"> <p>Loading changelog...</p>
{#each changelog[appVersion] as change} {:else if allVersions.length > 0}
<li>{change}</li> {#each allVersions as version}
{/each} <div class="changelog-version">
</ul> <h3 class="changelog-version-title">Version {version}</h3>
{#if changelog[version] && changelog[version].length > 0}
<ul class="changelog-list">
{#each changelog[version] as change}
<li>{change}</li>
{/each}
</ul>
{:else}
<p>No changelog available for this version.</p>
{/if}
</div>
{/each}
{:else} {:else}
<p>No changelog available for this version.</p> <p>No changelog available.</p>
{/if} {/if}
</div> </div>
</section> </section>
@ -313,6 +303,25 @@
background: var(--fog-dark-accent, #94a3b8); background: var(--fog-dark-accent, #94a3b8);
} }
.changelog-version {
margin-bottom: 2rem;
}
.changelog-version:last-child {
margin-bottom: 0;
}
.changelog-version-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: var(--fog-text, #1f2937);
}
:global(.dark) .changelog-version-title {
color: var(--fog-dark-text, #f9fafb);
}
.changelog-list { .changelog-list {
list-style: disc; list-style: disc;
padding-left: 1.5rem; padding-left: 1.5rem;

Loading…
Cancel
Save