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. 57
      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. 73
      src/routes/about/+page.svelte

3
package-lock.json generated

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

1
package.json

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

23
public/changelog.yaml

@ -0,0 +1,23 @@ @@ -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 @@ @@ -1,5 +1,6 @@
<script lang="ts">
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
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
@ -115,7 +116,11 @@ @@ -115,7 +116,11 @@
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) {
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
}

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

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
import { prewarmCaches, type PrewarmProgress } from '../../services/cache/cache-prewarmer.js';
import { markVersionUpdated, getAppVersion } from '../../services/version-manager.js';
import { getDB } from '../../services/cache/indexeddb-store.js';
import { getNewestChangelog } from '../../services/changelog.js';
import Icon from '../ui/Icon.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
@ -17,6 +18,7 @@ @@ -17,6 +18,7 @@
let showPWAUpdatePrompt = $state(false);
let appVersion = $state('0.2.0');
let isPWAInstalled = $state(false);
let newestChangelog = $state<string[]>([]);
let progress = $state<PrewarmProgress>({
step: 'Preparing update...',
progress: 0,
@ -26,6 +28,7 @@ @@ -26,6 +28,7 @@
onMount(async () => {
appVersion = await getAppVersion();
newestChangelog = await getNewestChangelog();
// Check if PWA is installed
if (typeof window !== 'undefined') {
@ -242,6 +245,18 @@ @@ -242,6 +245,18 @@
{#if !updating}
<div class="update-modal-content">
{#if newestChangelog.length > 0}
<div class="whats-new-section">
<h3 class="whats-new-title">What's New in Version {appVersion}</h3>
<ul class="whats-new-list">
{#each newestChangelog as change}
<li>{change}</li>
{/each}
</ul>
</div>
{/if}
<div class="update-process-section">
<p class="update-modal-description">
This update will:
</p>
@ -251,6 +266,7 @@ @@ -251,6 +266,7 @@
<li>Prepare caches for quick access</li>
</ul>
</div>
</div>
<div class="update-modal-actions">
<button
@ -408,6 +424,47 @@ @@ -408,6 +424,47 @@
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 {
margin: 1rem 0 0 0;
font-size: 0.875rem;

92
src/lib/services/changelog.ts

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

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

@ -0,0 +1,137 @@ @@ -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;
}

73
src/routes/about/+page.svelte

@ -4,11 +4,19 @@ @@ -4,11 +4,19 @@
import { goto } from '$app/navigation';
import Icon from '../../lib/components/ui/Icon.svelte';
import { getAppVersion } from '../../lib/services/version-manager.js';
import { getAllVersions, loadChangelog } from '../../lib/services/changelog.js';
let appVersion = $state('0.3.1');
let allVersions = $state<string[]>([]);
let changelog = $state<Record<string, string[]>>({});
let loadingChangelog = $state(true);
onMount(async () => {
appVersion = await getAppVersion();
allVersions = await getAllVersions();
const changelogData = await loadChangelog();
changelog = changelogData.versions;
loadingChangelog = false;
});
function handleBack() {
@ -18,35 +26,6 @@ @@ -18,35 +26,6 @@
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>
<Header />
@ -126,11 +105,17 @@ @@ -126,11 +105,17 @@
<!-- 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">
<h2 class="section-title">What's New in Version {appVersion}</h2>
<h2 class="section-title">Changelog</h2>
<div class="section-content">
{#if changelog[appVersion]}
{#if loadingChangelog}
<p>Loading changelog...</p>
{:else if allVersions.length > 0}
{#each allVersions as version}
<div class="changelog-version">
<h3 class="changelog-version-title">Version {version}</h3>
{#if changelog[version] && changelog[version].length > 0}
<ul class="changelog-list">
{#each changelog[appVersion] as change}
{#each changelog[version] as change}
<li>{change}</li>
{/each}
</ul>
@ -138,6 +123,11 @@ @@ -138,6 +123,11 @@
<p>No changelog available for this version.</p>
{/if}
</div>
{/each}
{:else}
<p>No changelog available.</p>
{/if}
</div>
</section>
<!-- Links -->
@ -313,6 +303,25 @@ @@ -313,6 +303,25 @@
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 {
list-style: disc;
padding-left: 1.5rem;

Loading…
Cancel
Save