Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/current-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { getEnvironment } from './environment.ts'
import { type Page, pages } from './pages/index.ts'
import { type Route, router } from './router.ts'

/**
* Iterates over all parameters of a page, calling the iterator function
* for each parameter store and its corresponding value from the route
*/
function eachParam<SomeRoute extends Route>(
page: Page<SomeRoute['route']>,
route: SomeRoute,
Expand All @@ -22,6 +26,9 @@ function eachParam<SomeRoute extends Route>(
}
}

/**
* Extracts current parameter values from all parameter stores of a page
*/
function getPageParams<SomeRoute extends Route>(
page: Page<SomeRoute['route']>
): SomeRoute['params'] {
Expand Down
4 changes: 4 additions & 0 deletions core/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export interface DownloadTask {
text(...args: Parameters<typeof request>): Promise<TextResponse>
}

/**
* Detects content type by analyzing the text content for common patterns
* like HTML doctype, XML declarations, RSS/Atom feed elements, and JSON Feed format
*/
function detectType(text: string): string | undefined {
let lower = text.toLowerCase()
let beginning = lower.slice(0, 100)
Expand Down
6 changes: 6 additions & 0 deletions core/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export function setIsMobile(isSmallScreen: boolean): void {

const testRouter = atom<BaseRoute | undefined>()

/**
* Ensures a route has a hash property, adding an empty string if missing
*/
export function addHashToBaseRoute(
route: BaseRoute | Omit<BaseRoute, 'hash'> | undefined
): BaseRoute | undefined {
Expand All @@ -144,6 +147,9 @@ export function setBaseTestRoute(
testRouter.set(addHashToBaseRoute(route))
}

/**
* Converts popup routes to a hash string format (popup=param,popup2=param2)
*/
export function stringifyPopups(popups: PopupRoute[]): string {
return popups
.map(({ param, popup }) => `${popup}=${param}`)
Expand Down
1 change: 1 addition & 0 deletions core/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export async function changeFeed(
await recalcPostsReading(feedId)
}

/** Subscribes to a feed which is currently being previewed */
export async function addCandidate(
candidate: FeedLoader,
fields: Partial<FeedValue> = {},
Expand Down
4 changes: 4 additions & 0 deletions core/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ export function sortFilters(filters: FilterValue[]): FilterValue[] {
})
}

/**
* Moves a filter up or down in priority by recalculating its priority value
* relative to neighboring filters to maintain sort order
*/
async function move(filterId: string, diff: -1 | 1): Promise<void> {
let store = Filter(filterId, getClient())
let filter = await loadValue(store)
Expand Down
23 changes: 23 additions & 0 deletions core/loader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,30 @@ import { jsonFeed } from './json-feed.ts'
import { rss } from './rss.ts'

export type Loader = {
/**
* Returns all urls found in the html document itself and in its http headers.
* Response here is the website response with a html document.
*/
getMineLinksFromText(response: TextResponse): string[]
/**
* Extracts the feed's posts, given feed download task or the response with
* the feed's xml.
*/
getPosts(task: DownloadTask, url: string, text?: TextResponse): PostsList
/**
* Given the website html response, returns the default feed url,
* e.g. https://example.com/rss for rss feed.
*/
getSuggestedLinksFromText(response: TextResponse): string[]
/**
* Returns feed title, if any
*/
isMineText(response: TextResponse): false | string
/**
* For instance, YouTube loader will return true for youtube.com links or Telegram loader for t.me links.
* It detects that URL is 100% for this loader.
* It is not used right now because there is no way to detect RSS/Atom link just by URL.
*/
isMineUrl(url: URL): false | string | undefined
}

Expand All @@ -29,6 +49,9 @@ export interface FeedLoader {
url: string
}

/**
* Decides which loader to use for the given feed response.
*/
export function getLoaderForText(response: TextResponse): false | FeedLoader {
let names = Object.keys(loaders) as LoaderName[]
let parsed = new URL(response.url)
Expand Down
13 changes: 13 additions & 0 deletions core/loader/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ export function isString(attr: null | string): attr is string {
return typeof attr === 'string' && attr.length > 0
}

/** Detects that the server responded with a html document */
export function isHTML(text: TextResponse): boolean {
return text.text.toLocaleLowerCase().includes('<html')
}

/**
* Given anchor or link element, returns their full url, which includes not only
* the explicitly provided base url, but also the base url specified in the document.
*/
function buildFullURL(
link: HTMLAnchorElement | HTMLLinkElement,
baseUrl: string
Expand All @@ -30,6 +35,9 @@ function buildFullURL(
)
}

/**
* Returns full urls found in the document's <link> elements
*/
export function findDocumentLinks(text: TextResponse, type: string): string[] {
let document = text.parseXml()
if (!document) return []
Expand All @@ -42,6 +50,9 @@ export function findDocumentLinks(text: TextResponse, type: string): string[] {
.map(link => buildFullURL(link, text.url))
}

/**
* Returns full urls found in the document's <a> elements
*/
export function findAnchorHrefs(
text: TextResponse,
hrefPattern: RegExp,
Expand Down Expand Up @@ -89,6 +100,7 @@ export function findHeaderLinks(
}, [])
}

/** Returns the unix timestamp of a date */
export function toTime(date: null | string | undefined): number | undefined {
if (!date) return undefined
let time = new Date(date).getTime() / 1000
Expand All @@ -109,6 +121,7 @@ export function findImageByAttr(
}, [])
}

/** Returns the unique elements without nulls from array */
export function unique<T extends number | string = string>(
collection: Iterable<null | T | undefined>
): T[] {
Expand Down
4 changes: 4 additions & 0 deletions core/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export let fastMenu = atom<CategoryValue[]>([])
export let slowMenu = atom<SlowMenu>([])
export let menuLoading = atom<boolean>(true)

/**
* Rebuilds the menu state by categorizing feeds and posts into fast/slow menus
* with unread counts and proper category organization
*/
async function rebuild(): Promise<void> {
let [posts, feeds, categories, fastFilters] = await Promise.all([
loadValue(getPosts()),
Expand Down
4 changes: 4 additions & 0 deletions core/opened-popups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { router } from './router.ts'

let prevPopups: Popup[] = []

/**
* Manages popup lifecycle by reusing existing popup instances when possible
* and destroying unused ones to prevent memory leaks
*/
export const openedPopups: ReadableAtom<Popup[]> = computed(router, route => {
let lastIndex = 0
let nextPopups = route.popups.map((popup, index) => {
Expand Down
18 changes: 18 additions & 0 deletions core/pages/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,13 @@ export type AddLinksValue = Record<
export const addPage = createPage('add', () => {
let $url = atom<string | undefined>()

/**
* Map of all urls found in the document with urls as keys and loading state
* as values.
*/
let $links = map<AddLinksValue>({})

/** List of pending feed urls extracted from the given url */
let $candidates = atom<FeedLoader[]>([])

let $error = computed(
Expand Down Expand Up @@ -111,27 +116,40 @@ export const addPage = createPage('add', () => {
$url.set(normalizedUrl)
})

/**
* Extracts links to all known feed types from the http response containing
* the html document
*/
function getLinksFromText(response: TextResponse): string[] {
let names = Object.keys(loaders) as LoaderName[]
return names.reduce<string[]>((links, name) => {
return links.concat(loaders[name].getMineLinksFromText(response))
}, [])
}

/** Returns a list of default / fallback links for all feed types */
function getSuggestedLinksFromText(response: TextResponse): string[] {
let names = Object.keys(loaders) as LoaderName[]
return names.reduce<string[]>((links, name) => {
return links.concat(loaders[name].getSuggestedLinksFromText(response))
}, [])
}

/**
* Adds a possible feed url, its meta and type, to the list of possible urls.
*/
function addCandidate(url: string, candidate: FeedLoader): void {
if ($candidates.get().some(i => i.url === url)) return

$links.setKey(url, { state: 'processed' })
$candidates.set([...$candidates.get(), candidate])
}

/**
* Given the link to the document, checks every link found in the document
* for a feed. Populates the list of pending urls, "candidates".
* Also accepts a direct feed link.
*/
async function addLink(
task: DownloadTask,
url: string,
Expand Down
4 changes: 4 additions & 0 deletions core/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export const refreshProgress = computed(refreshStatistics, stats => {
let task: DownloadTask
let queue: Queue<{ feed: FeedValue }>

/**
* Determines if a post was already added to a feed by comparing timestamps
* or origin IDs to prevent duplicate posts during refresh
*/
function wasAlreadyAdded(feed: FeedValue, origin: OriginPost): boolean {
if (origin.publishedAt && feed.lastPublishedAt) {
return origin.publishedAt <= feed.lastPublishedAt
Expand Down
4 changes: 4 additions & 0 deletions core/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ function checkPopupName(
return !!popup && popup in popupNames
}

/**
* Parses popup routes from hash string format (popup=param,popup2=param2)
* into an array of popup route objects
*/
export function parsePopups(hash: string): PopupRoute[] {
let popups: PopupRoute[] = []
let parts = hash.split(',')
Expand Down