├── assets ├── graphic.png └── graphic-2.png ├── src ├── public │ ├── icons │ │ ├── dev │ │ │ ├── 128.png │ │ │ ├── 16.png │ │ │ ├── 24.png │ │ │ ├── 256.png │ │ │ ├── 300.png │ │ │ ├── 32.png │ │ │ ├── 48.png │ │ │ └── 64.png │ │ └── production │ │ │ ├── 128.png │ │ │ ├── 16.png │ │ │ ├── 24.png │ │ │ ├── 256.png │ │ │ ├── 32.png │ │ │ ├── 48.png │ │ │ └── 64.png │ ├── index.html │ └── onboarding.html ├── components │ ├── index.ts │ ├── Layout.tsx │ ├── Tooltip.tsx │ ├── icons │ │ ├── MoonIcon.tsx │ │ ├── ArrowRightIcon.tsx │ │ ├── index.ts │ │ ├── SheetIcon.tsx │ │ ├── MoreIcon.tsx │ │ ├── AlertIcon.tsx │ │ ├── InfoIcon.tsx │ │ ├── BrokenLinkIcon.tsx │ │ ├── BookmarkIcon.tsx │ │ ├── ExternalLink.tsx │ │ ├── NewTab.tsx │ │ ├── WarningIcon.tsx │ │ ├── CsvFileIcon.tsx │ │ ├── JsonFileIcon.tsx │ │ ├── JsonCopyIcon.tsx │ │ ├── SunIcon.tsx │ │ ├── CsvCopyIcon.tsx │ │ └── ExportIcon.tsx │ ├── LinkList │ │ ├── index.tsx │ │ ├── RedirectedLink.tsx │ │ ├── LinkSuffix.tsx │ │ ├── LinkItem.tsx │ │ └── Domain.tsx │ ├── Header │ │ ├── ThemeToggle.tsx │ │ ├── FetchLoader.tsx │ │ └── index.tsx │ ├── ResponseTag.tsx │ ├── Favicon.tsx │ ├── Tabs │ │ ├── Tab.tsx │ │ ├── SearchFilter.tsx │ │ ├── SelectAll.tsx │ │ └── index.tsx │ ├── ActionsBar │ │ ├── index.tsx │ │ ├── OverflowActions.tsx │ │ ├── BookmarkModal.tsx │ │ └── TabGroupPopover.tsx │ ├── LinkRoamerLogo.tsx │ └── EmptyState.tsx ├── types.ts ├── scripts │ ├── execute-scripts.ts │ ├── data-debugger.ts │ ├── Link.ts │ ├── LinksHandler.ts │ ├── LinkActions.ts │ └── Chrome.ts ├── pages │ ├── App.tsx │ └── Onboarding.tsx ├── providers │ ├── CheckedItems.tsx │ ├── ThemeProvider.tsx │ └── DataProvider.tsx ├── v3-manifest-dev.json ├── v2-manifest.json ├── v3-manifest-prod.json ├── background.ts ├── api │ ├── index.ts │ └── LinkStatus.ts └── status-codes.ts ├── tsconfig.json ├── .eslintrc.json ├── LICENSE ├── rollup.config.js ├── .gitignore ├── package.json └── README.md /assets/graphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/assets/graphic.png -------------------------------------------------------------------------------- /assets/graphic-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/assets/graphic-2.png -------------------------------------------------------------------------------- /src/public/icons/dev/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/128.png -------------------------------------------------------------------------------- /src/public/icons/dev/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/16.png -------------------------------------------------------------------------------- /src/public/icons/dev/24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/24.png -------------------------------------------------------------------------------- /src/public/icons/dev/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/256.png -------------------------------------------------------------------------------- /src/public/icons/dev/300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/300.png -------------------------------------------------------------------------------- /src/public/icons/dev/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/32.png -------------------------------------------------------------------------------- /src/public/icons/dev/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/48.png -------------------------------------------------------------------------------- /src/public/icons/dev/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/64.png -------------------------------------------------------------------------------- /src/public/icons/production/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/128.png -------------------------------------------------------------------------------- /src/public/icons/production/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/16.png -------------------------------------------------------------------------------- /src/public/icons/production/24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/24.png -------------------------------------------------------------------------------- /src/public/icons/production/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/256.png -------------------------------------------------------------------------------- /src/public/icons/production/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/32.png -------------------------------------------------------------------------------- /src/public/icons/production/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/48.png -------------------------------------------------------------------------------- /src/public/icons/production/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/64.png -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CategoryTabs } from './Tabs' 2 | export { default as Header } from './Header' 3 | export { default as Layout } from './Layout' 4 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react' 2 | import React from 'react' 3 | import { Children } from '../types' 4 | 5 | const Layout = ({ children }: Children) => ( 6 | 7 | {children} 8 | 9 | ) 10 | 11 | export default Layout 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "lib": ["dom", "es2019"], 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "noImplicitReturns": true, 10 | "noUnusedLocals": true, 11 | "strict": true, 12 | "target": "es2018" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from './scripts/Link' 3 | 4 | export type CategorizedLinks = Record 5 | 6 | export type Message = { 7 | action: 'fetchLinks' 8 | data: string 9 | } 10 | 11 | export type LinkData = { 12 | loading: boolean 13 | links: Link[] 14 | } 15 | 16 | export type Children = { 17 | children: React.ReactNode 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip as ChakraTooltip, TooltipProps } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | const Tooltip = (props: TooltipProps) => ( 5 | {props.children} 6 | ) 7 | 8 | Tooltip.defaultProps = { 9 | hasArrow: true, 10 | fontSize: '12px', 11 | placement: 'left', 12 | borderRadius: 'lg', 13 | textAlign: 'center', 14 | p: 2, 15 | } 16 | 17 | export default Tooltip 18 | -------------------------------------------------------------------------------- /src/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Link Roamer 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/public/onboarding.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Link Roamer 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/icons/MoonIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const MoonIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: , 17 | }) 18 | -------------------------------------------------------------------------------- /src/components/LinkList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Accordion } from '@chakra-ui/react' 3 | import Domain from './Domain' 4 | import { CategorizedLinks } from '../../types' 5 | 6 | type Props = { 7 | categorizedLinks: CategorizedLinks 8 | } 9 | 10 | const LinkList = ({ categorizedLinks }: Props) => ( 11 | 12 | {Object.entries(categorizedLinks).map(([domain, links]) => ( 13 | 14 | ))} 15 | 16 | ) 17 | 18 | export default LinkList 19 | -------------------------------------------------------------------------------- /src/components/icons/ArrowRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const ArrowRight = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | ), 22 | }) 23 | -------------------------------------------------------------------------------- /src/components/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AlertIcon' 2 | export * from './ArrowRightIcon' 3 | export * from './BookmarkIcon' 4 | export * from './BrokenLinkIcon' 5 | export * from './CsvCopyIcon' 6 | export * from './CsvFileIcon' 7 | export * from './ExportIcon' 8 | export * from './ExternalLink' 9 | export * from './InfoIcon' 10 | export * from './JsonCopyIcon' 11 | export * from './JsonFileIcon' 12 | export * from './MoonIcon' 13 | export * from './MoreIcon' 14 | export * from './NewTab' 15 | export * from './SheetIcon' 16 | export * from './SunIcon' 17 | export * from './WarningIcon' 18 | -------------------------------------------------------------------------------- /src/scripts/execute-scripts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get all the hrefs on a given page and return an array of unique values. If no hrefs, returns an empty array. Must be an isolated function declaration because of v3 manifest's strict policies on script injection using executeScript. 3 | */ 4 | export function gatherHrefs() { 5 | const links = Array.from(document.links) 6 | const hrefs = links.map((link) => link.href) 7 | return [...new Set(hrefs)] 8 | } 9 | 10 | /** 11 | * Gets the target tabs domain name 12 | */ 13 | export function getDomain() { 14 | return document.location.host 15 | } 16 | -------------------------------------------------------------------------------- /src/components/icons/SheetIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const SheetIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | 18 | ), 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/icons/MoreIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const MoreIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/icons/AlertIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const AlertIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/icons/InfoIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const InfoIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/scripts/data-debugger.ts: -------------------------------------------------------------------------------- 1 | import Link from './Link' 2 | import LinksHandler from './LinksHandler' 3 | 4 | function dataDebugger(links: Link[]) { 5 | const lp = new LinksHandler(links) 6 | const invalid = links.filter((link) => !link.status.validResponse) 7 | 8 | console.log( 9 | 'Redirected links:', 10 | lp.redirectedLinks, 11 | lp.redirectedLinks.length 12 | ) 13 | console.log('Links with reponse status info:', links, links.length) 14 | console.log('Links that are not OK:', lp.notOkLinks, lp.notOkLinks.length) 15 | console.log('Empty link status:', invalid, invalid.length) 16 | } 17 | 18 | export default dataDebugger 19 | -------------------------------------------------------------------------------- /src/components/icons/BrokenLinkIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const BrokenLinkIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/Header/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, useColorMode } from '@chakra-ui/react' 2 | import React from 'react' 3 | import c from '../../scripts/Chrome' 4 | import { MoonIcon, SunIcon } from '../icons' 5 | 6 | const ThemeToggle = () => { 7 | const { colorMode, toggleColorMode } = useColorMode() 8 | 9 | const handleClick = () => { 10 | toggleColorMode() 11 | c.setStorage('mode', colorMode) 12 | } 13 | return ( 14 | : } 17 | onClick={handleClick} 18 | /> 19 | ) 20 | } 21 | 22 | export default ThemeToggle 23 | -------------------------------------------------------------------------------- /src/components/icons/BookmarkIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const BookmarkIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | 18 | ), 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/icons/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const ExternalLinkIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/ResponseTag.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Tag, TagProps, Text } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | type Props = TagProps & { 5 | quantity: number 6 | } 7 | 8 | const QuantityTag = (props: Props) => { 9 | const { quantity, children, colorScheme, ...rest } = props 10 | 11 | return ( 12 | 13 | {quantity > 1 && ( 14 | 15 | {quantity} 16 | 17 | x 18 | 19 | 20 | )} 21 | {children} 22 | 23 | ) 24 | } 25 | 26 | export default QuantityTag 27 | -------------------------------------------------------------------------------- /src/components/icons/NewTab.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const NewTabIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/icons/WarningIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const WarningIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/Favicon.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Image, useColorModeValue } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | type Props = { 5 | domain: string 6 | size?: number 7 | faviconSize?: number 8 | } 9 | 10 | const Favicon = ({ domain, size = 24, faviconSize = 16 }: Props) => { 11 | const bg = useColorModeValue('gray.100', 'gray.700') 12 | 13 | return ( 14 |
15 | 20 |
21 | ) 22 | } 23 | 24 | function faviconUrl(domain: string) { 25 | const url = 'https://s2.googleusercontent.com/s2/favicons?domain=' 26 | return url + domain 27 | } 28 | 29 | export default Favicon 30 | -------------------------------------------------------------------------------- /src/components/LinkList/RedirectedLink.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Link as ChakraLink, Tag, Text } from '@chakra-ui/react' 2 | import React from 'react' 3 | import Link from '../../scripts/Link' 4 | 5 | type Props = { 6 | link: Link 7 | } 8 | 9 | const RedirectedLink = ({ link }: Props) => { 10 | if (!link.status.redirected) return null 11 | 12 | return ( 13 | 14 | 15 | Redirect 16 | 17 | 23 | 24 | {link.status.url} 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | export default RedirectedLink 32 | -------------------------------------------------------------------------------- /src/components/Tabs/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, Tab, useColorModeValue } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | type CustomTabProps = { 5 | linksQty: number 6 | title: string 7 | alwaysShow?: boolean 8 | } 9 | 10 | const CustomTab = React.forwardRef( 11 | (props, ref) => { 12 | const border = useColorModeValue('blurple.500', 'blurple.400') 13 | 14 | if (props.linksQty < 1 && !props.alwaysShow) return null 15 | 16 | return ( 17 | 26 | {props.title} {props.linksQty} 27 | 28 | ) 29 | } 30 | ) 31 | 32 | export default CustomTab 33 | -------------------------------------------------------------------------------- /src/pages/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOMClient from 'react-dom/client' 3 | import { CategoryTabs, Header, Layout } from '../components' 4 | import ActionsBar from '../components/ActionsBar' 5 | import { CheckedItemsProvider } from '../providers/CheckedItems' 6 | import { DataProvider } from '../providers/DataProvider' 7 | import ThemeProvider from '../providers/ThemeProvider' 8 | 9 | const App = () => { 10 | return ( 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | const root = document.getElementById('root')! 26 | ReactDOMClient.createRoot(root).render() 27 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true, 6 | "webextensions": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": 12, 19 | "sourceType": "module" 20 | }, 21 | "plugins": ["react", "@typescript-eslint"], 22 | "rules": { 23 | "react/display-name": 0, 24 | "react/react-in-jsx-scope": 0, 25 | "@typescript-eslint/no-non-null-assertion": 0, 26 | "@typescript-eslint/no-explicit-any": 0, 27 | "@typescript-eslint/explicit-module-boundary-types": 0, 28 | "no-constant-condition": 0, 29 | "@typescript-eslint/ban-ts-comment": 0 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/icons/CsvFileIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const CsvFileIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/icons/JsonFileIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const JsonFileIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | ), 24 | }) 25 | -------------------------------------------------------------------------------- /src/components/Tabs/SearchFilter.tsx: -------------------------------------------------------------------------------- 1 | import { Box, FormLabel, Input, VisuallyHidden } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | type Props = { 5 | setFilter: React.Dispatch> 6 | } 7 | 8 | const SearchFilter = ({ setFilter }: Props) => { 9 | const handleChange = (event: React.ChangeEvent) => { 10 | setFilter(event.target.value) 11 | } 12 | 13 | return ( 14 | 15 | 16 | 17 | Filter links by typing in a keyword 18 | 19 | 20 | 29 | 30 | ) 31 | } 32 | 33 | export default SearchFilter 34 | -------------------------------------------------------------------------------- /src/providers/CheckedItems.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Children } from '../types' 3 | 4 | type CheckedItemsContextProps = { 5 | checkedItems: string[] 6 | setCheckedItems: React.Dispatch> 7 | } 8 | 9 | const CheckedItemsContext = React.createContext({} as CheckedItemsContextProps) 10 | 11 | export const CheckedItemsProvider = ({ children }: Children) => { 12 | const [checkedItems, setCheckedItems] = React.useState([]) 13 | 14 | const checkedMemo = React.useMemo(() => { 15 | const sorted = checkedItems.sort() 16 | 17 | return { 18 | checkedItems: sorted, 19 | setCheckedItems, 20 | } 21 | }, [checkedItems]) 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ) 28 | } 29 | 30 | export const useCheckedItems = () => React.useContext(CheckedItemsContext) 31 | -------------------------------------------------------------------------------- /src/components/icons/JsonCopyIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const JsonCopyIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/icons/SunIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const SunIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | <> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ), 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/icons/CsvCopyIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const CsvCopyIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | stroke: 'currentColor', 9 | strokeWidth: '2px', 10 | strokeLinecap: 'round', 11 | strokeLinejoin: 'round', 12 | fill: 'none', 13 | 'aria-hidden': true, 14 | }, 15 | viewBox: '0 0 24 24', 16 | path: ( 17 | 18 | 19 | 20 | 21 | 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ross Moody 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/v3-manifest-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Link Roamer Dev", 4 | "permissions": [ 5 | "activeTab", 6 | "scripting", 7 | "tabGroups", 8 | "bookmarks", 9 | "declarativeContent", 10 | "storage" 11 | ], 12 | "background": { 13 | "service_worker": "background.ts" 14 | }, 15 | "action": { 16 | "default_icon": { 17 | "16": "public/icons/dev/16.png", 18 | "24": "public/icons/dev/24.png", 19 | "32": "public/icons/dev/32.png" 20 | }, 21 | "default_popup": "public/index.html" 22 | }, 23 | "icons": { 24 | "48": "public/icons/dev/48.png", 25 | "64": "public/icons/dev/64.png", 26 | "128": "public/icons/dev/128.png", 27 | "256": "public/icons/dev/256.png" 28 | }, 29 | "commands": { 30 | "_execute_action": { 31 | "suggested_key": { 32 | "default": "Ctrl+U", 33 | "mac": "Command+U" 34 | } 35 | } 36 | }, 37 | "web_accessible_resources": [ 38 | { 39 | "resources": ["public/onboarding.html"], 40 | "matches": ["*://*/*"] 41 | } 42 | ], 43 | "host_permissions": [ 44 | "https://fetch-fav-h57lsidp3a-uc.a.run.app/*", 45 | "http://localhost:8080/*" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /src/v2-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Link Roamer", 4 | "description": "A browser extension for gathering, organizing, and inspecting all the links on a web page.", 5 | "homepage_url": "https://www.linkroamer.com", 6 | "permissions": [ 7 | "activeTab", 8 | "bookmarks", 9 | "storage", 10 | "https://fetch-fav-h57lsidp3a-uc.a.run.app/*" 11 | ], 12 | "background": { 13 | "scripts": ["background.ts"], 14 | "persistent": false 15 | }, 16 | "browser_action": { 17 | "default_icon": { 18 | "16": "public/icons/production/16.png", 19 | "24": "public/icons/production/24.png", 20 | "32": "public/icons/production/32.png" 21 | }, 22 | "default_popup": "public/index.html" 23 | }, 24 | "commands": { 25 | "_execute_browser_action": { 26 | "suggested_key": { 27 | "default": "Ctrl+U", 28 | "mac": "Command+U" 29 | } 30 | } 31 | }, 32 | "web_accessible_resources": ["public/onboarding.html"], 33 | "icons": { 34 | "48": "public/icons/production/48.png", 35 | "64": "public/icons/production/64.png", 36 | "128": "public/icons/production/128.png", 37 | "256": "public/icons/production/256.png" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Tabs/SelectAll.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox } from '@chakra-ui/react' 2 | import React from 'react' 3 | import { useCheckedItems } from '../../providers/CheckedItems' 4 | import Link from '../../scripts/Link' 5 | 6 | type Props = { 7 | filteredLinks: Link[] 8 | } 9 | 10 | const SelectAll = ({ filteredLinks }: Props) => { 11 | const { checkedItems, setCheckedItems } = useCheckedItems() 12 | 13 | const allChecked = 14 | filteredLinks.every((link) => checkedItems.includes(link.href)) && 15 | filteredLinks.length > 0 16 | 17 | const isIndeterminate = 18 | filteredLinks.some((link) => checkedItems.includes(link.href)) && 19 | !allChecked 20 | 21 | const handleChange = () => { 22 | const hrefs = filteredLinks.map((link) => link.href) 23 | 24 | allChecked 25 | ? setCheckedItems((prev) => prev.filter((item) => !hrefs.includes(item))) 26 | : setCheckedItems((prev) => [...prev, ...hrefs]) 27 | } 28 | 29 | return ( 30 | 31 | 40 | 41 | ) 42 | } 43 | 44 | export default SelectAll 45 | -------------------------------------------------------------------------------- /src/v3-manifest-prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Link Roamer", 4 | "description": "A browser extension for gathering, organizing, and inspecting all the links on a web page.", 5 | "homepage_url": "https://www.linkroamer.com", 6 | "permissions": [ 7 | "activeTab", 8 | "scripting", 9 | "tabGroups", 10 | "bookmarks", 11 | "declarativeContent", 12 | "storage" 13 | ], 14 | "background": { 15 | "service_worker": "background.ts" 16 | }, 17 | "action": { 18 | "default_icon": { 19 | "16": "public/icons/production/16.png", 20 | "24": "public/icons/production/24.png", 21 | "32": "public/icons/production/32.png" 22 | }, 23 | "default_popup": "public/index.html" 24 | }, 25 | "icons": { 26 | "48": "public/icons/production/48.png", 27 | "64": "public/icons/production/64.png", 28 | "128": "public/icons/production/128.png", 29 | "256": "public/icons/production/256.png" 30 | }, 31 | "commands": { 32 | "_execute_action": { 33 | "suggested_key": { 34 | "default": "Ctrl+U", 35 | "mac": "Command+U" 36 | } 37 | } 38 | }, 39 | "web_accessible_resources": [ 40 | { 41 | "resources": ["public/onboarding.html"], 42 | "matches": ["*://*/*"] 43 | } 44 | ], 45 | "host_permissions": ["https://fetch-fav-h57lsidp3a-uc.a.run.app/*"] 46 | } 47 | -------------------------------------------------------------------------------- /src/components/Header/FetchLoader.tsx: -------------------------------------------------------------------------------- 1 | import { Fade, Spinner, Tag, TagLabel } from '@chakra-ui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import { useData } from '../../providers/DataProvider' 4 | import LinksHandler from '../../scripts/LinksHandler' 5 | 6 | const initialConfig = { 7 | label: 'Checking links', 8 | colorScheme: 'gray', 9 | } 10 | 11 | const FetchLoader = () => { 12 | const [loading, setLoading] = useState(true) 13 | const [state, setState] = useState(initialConfig) 14 | const { data } = useData() 15 | 16 | useEffect(() => { 17 | if (!data.loading) { 18 | const brokenQty = new LinksHandler(data.links).fourOhFourLinks.length 19 | 20 | brokenQty 21 | ? setState({ 22 | label: `404${brokenQty < 2 ? '' : 's'} found`, 23 | colorScheme: 'red', 24 | }) 25 | : setState({ 26 | label: 'No 404s', 27 | colorScheme: 'green', 28 | }) 29 | 30 | setTimeout(setLoading, 2400, false) 31 | } 32 | }, [data.loading]) 33 | 34 | return ( 35 | 36 | 37 | {state.label} 38 | {data.loading && ( 39 | 47 | )} 48 | 49 | 50 | ) 51 | } 52 | 53 | export default FetchLoader 54 | -------------------------------------------------------------------------------- /src/providers/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider, ColorMode, extendTheme } from '@chakra-ui/react' 2 | import React from 'react' 3 | import c from '../scripts/Chrome' 4 | import { Children } from '../types' 5 | 6 | const theme = extendTheme({ 7 | styles: { 8 | global: { 9 | body: { 10 | fontSize: '100%', 11 | }, 12 | }, 13 | }, 14 | config: { 15 | initialColorMode: async () => await c.getStorage('mode'), 16 | }, 17 | semanticTokens: { 18 | colors: { 19 | textMuted: { 20 | default: 'gray.500', 21 | _dark: 'gray.400', 22 | }, 23 | }, 24 | }, 25 | colors: { 26 | blurple: { 27 | '50': '#E9E8FC', 28 | '100': '#C2C0F7', 29 | '200': '#9C97F2', 30 | '300': '#756EEC', 31 | '400': '#4E46E7', 32 | '500': '#271DE2', 33 | '600': '#1F17B5', 34 | '700': '#171188', 35 | '800': '#0F0C5A', 36 | '900': '#08062D', 37 | }, 38 | }, 39 | shadows: { 40 | outline: '0 0 0 2px var(--chakra-colors-blurple-200)', 41 | }, 42 | components: { 43 | Button: { 44 | defaultProps: { 45 | variant: 'outline', 46 | size: 'sm', 47 | }, 48 | }, 49 | Input: { 50 | defaultProps: { 51 | focusBorderColor: 'blurple.200', 52 | }, 53 | }, 54 | Tabs: { 55 | defaultProps: { 56 | colorScheme: 'blurple', 57 | }, 58 | }, 59 | Checkbox: { 60 | defaultProps: { 61 | colorScheme: 'blurple', 62 | }, 63 | }, 64 | }, 65 | }) 66 | 67 | const ThemeProvider = ({ children }: Children) => ( 68 | {children} 69 | ) 70 | 71 | export default ThemeProvider 72 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './types' 2 | 3 | const url = 4 | process.env.NODE_ENV === 'production' 5 | ? 'https://fetch-fav-h57lsidp3a-uc.a.run.app' 6 | : 'http://localhost:8080' 7 | 8 | /** 9 | * Listens for a message from the extension to fetch HEAD information 10 | * about each given link to check if it returns a 404 or not. 11 | */ 12 | chrome.runtime.onMessage.addListener( 13 | (message: Message, sender, sendResponse) => { 14 | if (message.action === 'fetchLinks') { 15 | const init: RequestInit = { 16 | method: 'POST', 17 | headers: { 'Content-Type': 'text/plain' }, 18 | body: message.data, 19 | } 20 | 21 | try { 22 | fetch(url, init) 23 | .then((result) => result.json()) 24 | .then(sendResponse) 25 | } catch (error) { 26 | sendResponse([]) 27 | } 28 | } 29 | 30 | return true 31 | } 32 | ) 33 | 34 | /** 35 | * Sets the extension to disabled by default and makes it possible 36 | * to invoke only on http schemed pages. 37 | */ 38 | if ('isV3Manifest') { 39 | chrome.runtime.onInstalled.addListener(() => { 40 | chrome.action.disable() 41 | 42 | const enableOnHttpPages = { 43 | conditions: [ 44 | new chrome.declarativeContent.PageStateMatcher({ 45 | pageUrl: { schemes: ['https', 'http'] }, 46 | }), 47 | ], 48 | actions: [new chrome.declarativeContent.ShowAction()], 49 | } 50 | 51 | chrome.declarativeContent.onPageChanged.addRules([enableOnHttpPages]) 52 | }) 53 | } 54 | 55 | chrome.runtime.onInstalled.addListener((details) => { 56 | if (details.reason === 'install') { 57 | chrome.tabs.create({ url: 'public/onboarding.html' }) 58 | } 59 | }) 60 | -------------------------------------------------------------------------------- /src/components/LinkList/LinkSuffix.tsx: -------------------------------------------------------------------------------- 1 | import { Fade, HStack, IconButton, Tag, TagLeftIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | import c from '../../scripts/Chrome' 4 | import Link from '../../scripts/Link' 5 | import statusCodes from '../../status-codes' 6 | import { ExternalLinkIcon, InfoIcon } from '../icons' 7 | import Tooltip from '../Tooltip' 8 | 9 | type Props = { 10 | link: Link 11 | hover: boolean 12 | } 13 | 14 | const LinkSuffix = ({ link, hover }: Props) => { 15 | const isHttp = link.protocol === 'http:' 16 | const isNotOk = !link.status.ok 17 | const statusCode = link.status.status as keyof typeof statusCodes 18 | 19 | return ( 20 | 21 | 22 | 26 | } 30 | onClick={() => c.createBackgroundTab(link.href)} 31 | /> 32 | 33 | 34 | {isHttp && ( 35 | 36 | HTTP 37 | 38 | )} 39 | {isNotOk && ( 40 | 46 | 47 | 48 | {statusCode} 49 | 50 | 51 | )} 52 | 53 | ) 54 | } 55 | 56 | export default LinkSuffix 57 | -------------------------------------------------------------------------------- /src/scripts/Link.ts: -------------------------------------------------------------------------------- 1 | import LinkStatus from '../api/LinkStatus' 2 | 3 | class Link extends URL { 4 | /** 5 | * The response status of a link's fetch result. Defaults to success scenario until proven otherwise. 6 | */ 7 | status = new LinkStatus(this.href) 8 | 9 | constructor(href: string) { 10 | super(href) 11 | } 12 | 13 | /** 14 | * Returns whether or not the protocol is http since links can be phone or address too. 15 | */ 16 | get isHttp() { 17 | return this.protocol.includes('http') 18 | } 19 | 20 | /** 21 | * Returns hostname without 'www.' 22 | */ 23 | get domain() { 24 | return this.hostname.replace('www.', '') 25 | } 26 | 27 | /** 28 | * Create a pretty version of the domain + pathname of a URL 29 | */ 30 | get displayHref() { 31 | const href = this.domain + this.pathname + this.hash 32 | const lastCharacter = href.charAt(href.length - 1) 33 | return lastCharacter === '/' ? href.slice(0, -1) : href 34 | } 35 | 36 | /** 37 | * The URL class this is extending has a custom toJSON method that only returns the href of the object. This is a hack to clone the object for serializing it. 38 | */ 39 | clone() { 40 | return { 41 | ...this, 42 | hash: this.hash, 43 | host: this.host, 44 | hostname: this.hostname, 45 | href: this.href, 46 | origin: this.origin, 47 | password: this.password, 48 | pathname: this.pathname, 49 | port: this.port, 50 | protocol: this.protocol, 51 | search: this.search, 52 | searchParams: this.searchParams, 53 | username: this.username, 54 | displayHref: this.displayHref, 55 | domain: this.domain, 56 | isHttp: this.isHttp, 57 | } 58 | } 59 | } 60 | 61 | export default Link 62 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Heading, Stack, Text } from '@chakra-ui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import { useData } from '../../providers/DataProvider' 4 | import c from '../../scripts/Chrome' 5 | import { getDomain } from '../../scripts/execute-scripts' 6 | import lp from '../../scripts/LinksHandler' 7 | import Favicon from '../Favicon' 8 | import FetchLoader from './FetchLoader' 9 | import ThemeToggle from './ThemeToggle' 10 | 11 | const Header = () => { 12 | const [domain, setDomain] = useState('') 13 | const { data } = useData() 14 | 15 | useEffect(() => { 16 | const fetchData = async () => { 17 | const { id } = await c.getActiveTab() 18 | 19 | if (id) { 20 | const domainName = await c.executeScript(id, getDomain) 21 | setDomain(domainName) 22 | } 23 | } 24 | 25 | fetchData().catch(() => setDomain('Website')) 26 | }, [data]) 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | {domain} 36 | 37 | 38 | 39 | Found {data.links.length} links across{' '} 40 | {Object.keys(lp.categorizeByDomain(data.links)).length} different 41 | domains 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ) 51 | } 52 | 53 | export default Header 54 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { http } from '@google-cloud/functions-framework' 2 | import fetch, { AbortError, RequestInit } from 'node-fetch' 3 | import UserAgent from 'user-agents' 4 | import LinkStatus from './LinkStatus' 5 | 6 | const getStatus = (link: string) => { 7 | async function fetchStatus(retries: number, method = 'HEAD') { 8 | const userAgent = new UserAgent() 9 | const controller = new globalThis.AbortController() 10 | 11 | const timeout = setTimeout(() => { 12 | controller.abort() 13 | }, 12000) 14 | 15 | const options: RequestInit = { 16 | headers: { 'User-Agent': userAgent.toString() }, 17 | method, 18 | signal: controller.signal, 19 | size: 0, 20 | } 21 | 22 | try { 23 | const response = await fetch(link, options) 24 | 25 | clearTimeout(timeout) 26 | 27 | if (response.status === 405 && retries >= 0) { 28 | fetchStatus(retries - 1, 'GET') 29 | } 30 | 31 | if (response.status === 404 && retries >= 0) { 32 | fetchStatus(retries - 1, method) 33 | } 34 | 35 | return new LinkStatus(link, response) 36 | } catch (error) { 37 | if (error instanceof AbortError) { 38 | return new LinkStatus(link) 39 | } 40 | 41 | if (retries >= 0) { 42 | fetchStatus(retries - 1, method) 43 | } 44 | 45 | return new LinkStatus(link) 46 | } 47 | } 48 | 49 | return fetchStatus(5) 50 | } 51 | 52 | const resolveSettledPromises = (promise: PromiseSettledResult) => { 53 | switch (promise.status) { 54 | case 'fulfilled': 55 | return promise.value 56 | 57 | case 'rejected': 58 | return new LinkStatus('') 59 | } 60 | } 61 | 62 | http('fetchStatuses', async (request, response) => { 63 | const links: string[] = JSON.parse(request.body) 64 | const results = (await Promise.allSettled(links.map(getStatus))).map( 65 | resolveSettledPromises 66 | ) 67 | response.send(JSON.stringify(results)) 68 | }) 69 | -------------------------------------------------------------------------------- /src/components/ActionsBar/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | ButtonGroup, 4 | Flex, 5 | SlideFade, 6 | Tag, 7 | useColorModeValue, 8 | } from '@chakra-ui/react' 9 | import React from 'react' 10 | import { useCheckedItems } from '../../providers/CheckedItems' 11 | import c from '../../scripts/Chrome' 12 | import { ExternalLinkIcon } from '../icons' 13 | import OverflowActions from './OverflowActions' 14 | import TabGroupPopover from './TabGroupPopover' 15 | 16 | const ActionsBar = () => { 17 | const { checkedItems } = useCheckedItems() 18 | const checkedItemsQty = checkedItems.length 19 | const showFooter = checkedItemsQty > 0 20 | 21 | const bg = useColorModeValue('white', 'gray.800') 22 | const border = useColorModeValue('gray.200', 'gray.600') 23 | 24 | async function createNewWindowTabs() { 25 | await c.createTabsInNewWindow(checkedItems) 26 | } 27 | 28 | return ( 29 | 30 | 31 | 43 | 44 | {checkedItemsQty} selected 45 | 46 | 47 | 48 | 55 | {'isV3Manifest' && } 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | 63 | export default ActionsBar 64 | -------------------------------------------------------------------------------- /src/scripts/LinksHandler.ts: -------------------------------------------------------------------------------- 1 | import { CategorizedLinks } from '../types' 2 | import Link from './Link' 3 | 4 | class LinksHandler { 5 | public links: Link[] 6 | 7 | constructor(links: Link[]) { 8 | this.links = links.sort((linkA, linkB) => 9 | linkA.href.length > linkB.href.length ? 0 : -1 10 | ) 11 | } 12 | 13 | /** 14 | * Creates a Record of Links categorized by available domain names. Returns 15 | * a Record of Links with keys as domains. 16 | */ 17 | static categorizeByDomain(links: Link[]) { 18 | return links.reduce((accum, link: Link) => { 19 | const { domain } = link 20 | if (!accum[domain]) accum[domain] = [] 21 | accum[domain].push(link) 22 | return accum 23 | }, {} as CategorizedLinks) 24 | } 25 | 26 | /** 27 | * Creates a Record of class instance's hrefs categorized by available domain names. 28 | */ 29 | get categorizedByDomain() { 30 | return LinksHandler.categorizeByDomain(this.links) 31 | } 32 | 33 | /** 34 | * Filter links to include only those with fragments as a Link[] 35 | */ 36 | get fragmentLinks() { 37 | return this.links.filter((link) => Boolean(link.href.includes('#'))) 38 | } 39 | 40 | /** 41 | * Return all the links that aren't status ok as a Link[] 42 | */ 43 | get notOkLinks() { 44 | return this.links.filter( 45 | (link) => !link.status.ok && link.status.status !== 999 46 | ) 47 | } 48 | 49 | /** 50 | * Return all the links that aren't redirected as a Link[] 51 | */ 52 | get redirectedLinks() { 53 | return this.links.filter((link) => link.status.redirected) 54 | } 55 | 56 | /** 57 | * Return all the links that aren't redirected as a Link[] 58 | */ 59 | get httpLinks() { 60 | return this.links.filter((link) => link.protocol === 'http:') 61 | } 62 | 63 | /** 64 | * Return all the links that aren't redirected as a Link[] 65 | */ 66 | get fourOhFourLinks() { 67 | return this.links.filter((link) => link.status.status === 404) 68 | } 69 | } 70 | 71 | export default LinksHandler 72 | -------------------------------------------------------------------------------- /src/components/icons/ExportIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export const ExportIcon = createIcon({ 5 | defaultProps: { 6 | width: '16px', 7 | height: '16px', 8 | fill: 'currentcolor', 9 | 'aria-hidden': true, 10 | }, 11 | viewBox: '0 0 24 24', 12 | path: ( 13 | <> 14 | 15 | 16 | ), 17 | }) 18 | -------------------------------------------------------------------------------- /src/components/LinkList/LinkItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Checkbox, 3 | Flex, 4 | Link as ChakraLink, 5 | ListItem, 6 | Text, 7 | } from '@chakra-ui/react' 8 | import React, { useState } from 'react' 9 | import { useCheckedItems } from '../../providers/CheckedItems' 10 | import Link from '../../scripts/Link' 11 | import LinkSuffix from './LinkSuffix' 12 | import RedirectedLink from './RedirectedLink' 13 | 14 | type Props = { 15 | link: Link 16 | } 17 | 18 | const LinkItem = ({ link }: Props) => { 19 | const [hover, setHover] = useState(false) 20 | const { checkedItems, setCheckedItems } = useCheckedItems() 21 | 22 | const handleChange = (event: React.ChangeEvent) => { 23 | const checked = event.target.checked 24 | const value = event.target.value 25 | 26 | checked 27 | ? setCheckedItems((prevChecked) => [...prevChecked, value]) 28 | : setCheckedItems((prevChecked) => 29 | prevChecked.filter((href) => href !== value) 30 | ) 31 | } 32 | 33 | return ( 34 | setHover(true)} 37 | onMouseLeave={() => setHover(false)} 38 | onFocus={() => setHover(true)} 39 | onBlur={() => setHover(false)} 40 | > 41 | 42 | 43 | 51 | 52 | 58 | 59 | {link.href} 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ) 69 | } 70 | 71 | export default LinkItem 72 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import json from '@rollup/plugin-json' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import replace from '@rollup/plugin-replace' 5 | import typescript from '@rollup/plugin-typescript' 6 | import path from 'path' 7 | import { chromeExtension, simpleReloader } from 'rollup-plugin-chrome-extension' 8 | import { emptyDir } from 'rollup-plugin-empty-dir' 9 | import zip from 'rollup-plugin-zip' 10 | 11 | const isProduction = process.env.NODE_ENV === 'production' 12 | 13 | const NODE_ENV = isProduction 14 | ? JSON.stringify('production') 15 | : JSON.stringify('development') 16 | 17 | const apiConfig = { 18 | input: 'src/api/index.ts', 19 | output: { 20 | dir: 'dist/api', 21 | format: 'esm', 22 | }, 23 | plugins: [typescript()], 24 | external: ['node-fetch', '@google-cloud/functions-framework', 'user-agents'], 25 | } 26 | 27 | const v2Manifest = { 28 | input: 'src/v2-manifest.json', 29 | output: { 30 | dir: 'dist/v2-manifest', 31 | format: 'esm', 32 | chunkFileNames: path.join('chunks', '[name]-[hash].js'), 33 | }, 34 | plugins: [ 35 | replace({ 36 | 'process.env.NODE_ENV': NODE_ENV, 37 | isV3Manifest: '', 38 | preventAssignment: true, 39 | }), 40 | chromeExtension(), 41 | simpleReloader(), 42 | json(), 43 | resolve(), 44 | commonjs(), 45 | typescript(), 46 | emptyDir(), 47 | isProduction && zip({ dir: 'releases/v2-manifest' }), 48 | ], 49 | } 50 | 51 | const v3Manifest = { 52 | input: isProduction 53 | ? 'src/v3-manifest-prod.json' 54 | : 'src/v3-manifest-dev.json', 55 | output: { 56 | dir: 'dist/v3-manifest', 57 | format: 'esm', 58 | chunkFileNames: path.join('chunks', '[name]-[hash].js'), 59 | }, 60 | plugins: [ 61 | replace({ 62 | 'process.env.NODE_ENV': NODE_ENV, 63 | isV3Manifest: 'true', 64 | preventAssignment: true, 65 | }), 66 | chromeExtension(), 67 | simpleReloader(), 68 | json(), 69 | resolve(), 70 | commonjs(), 71 | typescript(), 72 | emptyDir(), 73 | isProduction && zip({ dir: 'releases/v3-manifest' }), 74 | ], 75 | } 76 | 77 | const exports = isProduction 78 | ? [v2Manifest, v3Manifest] 79 | : [apiConfig, v3Manifest] 80 | 81 | export default exports 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Chrome extension release zip files 2 | releases 3 | .idea 4 | Link Roamer 5 | 6 | # Logs 7 | .DS_Store 8 | src/.DS_Store 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # TypeScript v1 declaration files 52 | typings/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variables file 79 | .env 80 | .env.test 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | 85 | # Next.js build output 86 | .next 87 | 88 | # Nuxt.js build / generate output 89 | .nuxt 90 | dist 91 | 92 | # Gatsby files 93 | .cache/ 94 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 95 | # https://nextjs.org/blog/next-9-1#public-directory-support 96 | # public 97 | 98 | # vuepress build output 99 | .vuepress/dist 100 | 101 | # Serverless directories 102 | .serverless/ 103 | 104 | # FuseBox cache 105 | .fusebox/ 106 | 107 | # DynamoDB Local files 108 | .dynamodb/ 109 | 110 | # TernJS port file 111 | .tern-port 112 | .DS_Store 113 | src/.DS_Store 114 | -------------------------------------------------------------------------------- /src/api/LinkStatus.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'node-fetch' 2 | import Link from '../scripts/Link' 3 | 4 | class LinkStatus { 5 | /** 6 | * A boolean indicating whether the response was successful (status in the range 200–299) or not. Defaults to true in case links are timed out. 7 | */ 8 | readonly ok: boolean 9 | 10 | /** 11 | * When an instance is created without a response, this defaults to false to check if the instance is valid. 12 | */ 13 | readonly validResponse: boolean 14 | 15 | /** 16 | * The original url used to fetch. Important when links are redirected as the url returned from response is final destination. 17 | */ 18 | readonly originUrl: Link['href'] 19 | 20 | /** 21 | * Indicates whether or not the response is the result of a redirect (that is, its URL list has more than one entry). 22 | */ 23 | readonly redirected: boolean 24 | 25 | /** 26 | * The Headers object associated with the response. 27 | */ 28 | readonly headers?: Record 29 | 30 | /** 31 | * The status code of the response. (This will be 200 for a success). 32 | */ 33 | readonly status?: number 34 | 35 | /** 36 | * The status message corresponding to the status code. (e.g., OK for 200). 37 | */ 38 | readonly statusText?: string 39 | 40 | /** 41 | * The type of the response (e.g., basic, cors). 42 | * */ 43 | readonly type?: ResponseType 44 | 45 | /** 46 | * The final URL of the response after redirects. 47 | * */ 48 | readonly url?: string 49 | 50 | constructor(originUrl: string, response?: Response) { 51 | this.originUrl = originUrl 52 | this.ok = true 53 | this.validResponse = false 54 | this.redirected = false 55 | 56 | if (response) { 57 | this.validResponse = true 58 | this.ok = response.ok 59 | this.redirected = response.redirected 60 | this.status = response.status 61 | this.statusText = response.statusText 62 | this.type = response.type 63 | this.url = response.url 64 | this.headers = this.setHeaders(response) 65 | } 66 | } 67 | 68 | private setHeaders(response: Response) { 69 | const entries = Array.from(response.headers.entries()) 70 | return entries.reduce((accumulator, [key, value]) => { 71 | accumulator[key] = JSON.stringify(value) 72 | return accumulator 73 | }, {} as Record) 74 | } 75 | } 76 | 77 | export default LinkStatus 78 | -------------------------------------------------------------------------------- /src/components/ActionsBar/OverflowActions.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconButton, 3 | Menu, 4 | MenuButton, 5 | MenuDivider, 6 | MenuItem, 7 | MenuList, 8 | } from '@chakra-ui/react' 9 | import React from 'react' 10 | import { useCheckedItems } from '../../providers/CheckedItems' 11 | import { useData } from '../../providers/DataProvider' 12 | import LinkActions from '../../scripts/LinkActions' 13 | import { 14 | BookmarkIcon, 15 | CsvCopyIcon, 16 | CsvFileIcon, 17 | ExportIcon, 18 | JsonCopyIcon, 19 | JsonFileIcon, 20 | MoreIcon, 21 | } from '../icons' 22 | import BookmarkModal from './BookmarkModal' 23 | 24 | const OverflowActions = () => { 25 | const [showBookmark, setShowBookmark] = React.useState(false) 26 | const { data } = useData() 27 | const { checkedItems } = useCheckedItems() 28 | 29 | return ( 30 | 31 | 32 | } 37 | /> 38 | 39 | 40 | setShowBookmark(true)} 42 | icon={} 43 | > 44 | Bookmark links 45 | 46 | 47 | LinkActions.jsonToFile(checkedItems)} 49 | icon={} 50 | > 51 | Export as JSON 52 | 53 | LinkActions.csvToFile(checkedItems)} 55 | icon={} 56 | > 57 | Export as CSV 58 | 59 | 60 | LinkActions.jsonToClipboard(checkedItems)} 62 | icon={} 63 | > 64 | Copy JSON 65 | 66 | LinkActions.csvToClipboard(checkedItems)} 68 | icon={} 69 | > 70 | Copy CSV 71 | 72 | 73 | LinkActions.exportAllData(data.links, checkedItems)} 75 | icon={} 76 | > 77 | Export detailed JSON 78 | 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | 86 | export default OverflowActions 87 | -------------------------------------------------------------------------------- /src/providers/DataProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from 'react' 2 | import LinkStatus from '../api/LinkStatus' 3 | import c from '../scripts/Chrome' 4 | import dataDebugger from '../scripts/data-debugger' 5 | import { gatherHrefs } from '../scripts/execute-scripts' 6 | import Link from '../scripts/Link' 7 | import { default as LinksHandler } from '../scripts/LinksHandler' 8 | import { Children, LinkData } from '../types' 9 | 10 | interface DataContextProps { 11 | data: LinkData 12 | setData: React.Dispatch> 13 | } 14 | 15 | const DataContext = React.createContext({} as DataContextProps) 16 | 17 | export const DataProvider = ({ children }: Children) => { 18 | const [data, setData] = useState({ 19 | links: [], 20 | loading: true, 21 | }) 22 | 23 | const dataMemo = useMemo( 24 | () => ({ 25 | data, 26 | setData, 27 | }), 28 | [data] 29 | ) 30 | 31 | useEffect(() => { 32 | const fetchData = async () => { 33 | const { id } = await c.getActiveTab() 34 | 35 | if (id) { 36 | const { links } = new LinksHandler( 37 | (await c.executeScript(id, gatherHrefs)) 38 | .map((link) => new Link(link)) 39 | .filter((link) => link.isHttp) 40 | ) 41 | 42 | links.length > 0 43 | ? setData({ links, loading: true }) 44 | : setData({ links: [], loading: false }) 45 | } 46 | } 47 | 48 | fetchData().catch(console.error) 49 | }, []) 50 | 51 | useEffect(() => { 52 | const fetchData = async () => { 53 | if (data.links.length > 0) { 54 | const result = await c.fetchLinks(data.links) 55 | 56 | /** 57 | * A completely failed attempt returns an empty Array. 58 | */ 59 | if (result.length < 1) 60 | return setData((prevData) => ({ ...prevData, loading: false })) 61 | 62 | const links = data.links.map((link) => { 63 | link.status = 64 | result.find(({ originUrl }) => originUrl === link.href) ?? 65 | new LinkStatus(link.href) 66 | return link 67 | }) 68 | 69 | if (process.env.NODE_ENV === 'development') { 70 | dataDebugger(links) 71 | } 72 | 73 | setData({ links, loading: false }) 74 | } 75 | } 76 | 77 | fetchData().catch(console.error) 78 | }, [data.links.length]) 79 | 80 | return ( 81 | {children} 82 | ) 83 | } 84 | 85 | export const useData = () => React.useContext(DataContext) 86 | -------------------------------------------------------------------------------- /src/components/ActionsBar/BookmarkModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | ButtonGroup, 4 | FormLabel, 5 | Input, 6 | Modal, 7 | ModalBody, 8 | ModalCloseButton, 9 | ModalContent, 10 | ModalFooter, 11 | ModalHeader, 12 | ModalOverlay, 13 | Text, 14 | VisuallyHidden, 15 | } from '@chakra-ui/react' 16 | import React from 'react' 17 | import ReactFocusLock from 'react-focus-lock' 18 | import { useCheckedItems } from '../../providers/CheckedItems' 19 | import c from '../../scripts/Chrome' 20 | import Link from '../../scripts/Link' 21 | 22 | type Props = { 23 | state: boolean 24 | setState: React.Dispatch> 25 | } 26 | 27 | const BookmarkModal = ({ state, setState }: Props) => { 28 | const { checkedItems } = useCheckedItems() 29 | const inputRef = React.useRef(null) 30 | 31 | async function createBookmark() { 32 | const { id } = await c.createBookmarkFolder( 33 | inputRef.current?.value ?? 'Link Roamer' 34 | ) 35 | 36 | checkedItems.forEach((url) => { 37 | const prettyUrl = new Link(url).displayHref 38 | c.createBookmark(prettyUrl, id, url) 39 | }) 40 | 41 | setState(false) 42 | } 43 | 44 | return ( 45 | setState(false)} 48 | size="sm" 49 | initialFocusRef={inputRef} 50 | > 51 | 52 | 53 | 54 | Set a folder title 55 | 56 | 57 | 58 | The folder will be created initially inside the "Other 59 | Bookmarks" directory. 60 | 61 | 62 | 63 | Set a bookmark folder title 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 78 | 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | 86 | export default BookmarkModal 87 | -------------------------------------------------------------------------------- /src/scripts/LinkActions.ts: -------------------------------------------------------------------------------- 1 | import Link from './Link' 2 | 3 | class LinkActions { 4 | /** 5 | * Transforms all link hrefs into a comma separated string 6 | */ 7 | static linksToCsvString(links: string[]) { 8 | return links.reduce((prevValue, currValue) => { 9 | return prevValue.concat(currValue, ',') 10 | }, '') 11 | } 12 | 13 | /** 14 | * Returns a single string of hrefs separated by commas 15 | */ 16 | static linksToJsonString(links: string[]) { 17 | const jsonified = links.reduce((prevValue, currValue) => { 18 | return prevValue.concat('"', currValue, '"', ',') 19 | }, '') 20 | 21 | return '[' + jsonified + ']' 22 | } 23 | 24 | static exportAllData(data: Link[], checkedItems: string[]) { 25 | const links = JSON.stringify( 26 | data 27 | .filter((item) => checkedItems.some((href) => href === item.href)) 28 | .map((item) => item.clone()) 29 | ) 30 | 31 | const json = 32 | 'data:application/json;charset=utf-8,' + encodeURIComponent(links) 33 | 34 | const link = document.createElement('a') 35 | link.setAttribute('href', json) 36 | link.setAttribute('download', 'detailed-roamer-data.json') 37 | document.body.appendChild(link) 38 | link.click() 39 | } 40 | 41 | /** 42 | * Save given links to a csv file 43 | */ 44 | static csvToFile(links: string[]) { 45 | const csv = 'data:text/csv;charset=utf-8,' + this.linksToCsvString(links) 46 | const encodedUri = encodeURI(csv) 47 | const link = document.createElement('a') 48 | link.setAttribute('href', encodedUri) 49 | link.setAttribute('download', 'link-roamer-data.csv') 50 | document.body.appendChild(link) 51 | link.click() 52 | } 53 | 54 | static jsonToFile(links: string[]) { 55 | const string = this.linksToJsonString(links) 56 | const json = 57 | 'data:application/json;charset=utf-8,' + encodeURIComponent(string) 58 | const link = document.createElement('a') 59 | link.setAttribute('href', json) 60 | link.setAttribute('download', 'link-roamer-data.json') 61 | document.body.appendChild(link) 62 | link.click() 63 | } 64 | 65 | static async jsonToClipboard(links: string[]) { 66 | const value = this.linksToJsonString(links) 67 | await this.copyToClipBoard(value) 68 | } 69 | 70 | static async csvToClipboard(links: string[]) { 71 | const value = this.linksToCsvString(links) 72 | await this.copyToClipBoard(value) 73 | } 74 | 75 | private static async copyToClipBoard(value: string) { 76 | await navigator.clipboard.writeText(value).catch(console.error) 77 | } 78 | } 79 | 80 | export default LinkActions 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "link-roamer", 3 | "version": "1.2.0", 4 | "description": "A browser extension for gathering, organizing, and inspecting all the links on a web page.", 5 | "scripts": { 6 | "release": "cross-env NODE_ENV=production rollup -c", 7 | "safari": "xcrun safari-web-extension-converter ./dist/v2-manifest", 8 | "server": "npx functions-framework --target fetchStatuses --source dist/api", 9 | "start": "rollup -c -w", 10 | "watch": "npm-watch server" 11 | }, 12 | "watch": { 13 | "server": "dist/api/*.js" 14 | }, 15 | "type": "module", 16 | "keywords": [ 17 | "chrome-extension", 18 | "web-extension", 19 | "typescript", 20 | "react", 21 | "rollup" 22 | ], 23 | "engines": { 24 | "node": ">=16.0.0" 25 | }, 26 | "author": "Ross Moody <@_rossmoody>", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/rossmoody/link-roamer/issues" 30 | }, 31 | "homepage": "https://github.com/rossmoody/link-roamer#readme", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/rossmoody/link-roamer.git" 35 | }, 36 | "dependencies": { 37 | "@chakra-ui/react": "^1.8.7", 38 | "@emotion/react": "^11", 39 | "@emotion/styled": "^11", 40 | "framer-motion": "^6", 41 | "react": "^18.0.0", 42 | "react-dom": "^18.0.0", 43 | "react-focus-lock": "^2.8.1", 44 | "user-agents": "^1.0.996" 45 | }, 46 | "devDependencies": { 47 | "@google-cloud/functions-framework": "^3.1.0", 48 | "@rollup/plugin-alias": "^3.1.1", 49 | "@rollup/plugin-commonjs": "^17.0.0", 50 | "@rollup/plugin-json": "^4.1.0", 51 | "@rollup/plugin-node-resolve": "^11.0.1", 52 | "@rollup/plugin-replace": "^3.0.0", 53 | "@rollup/plugin-typescript": "^8.1.0", 54 | "@types/chrome": "^0.0.180", 55 | "@types/express": "^4.17.13", 56 | "@types/firefox-webext-browser": "^94.0.0", 57 | "@types/react": "^18.0.0", 58 | "@types/react-dom": "^18.0.0", 59 | "@types/user-agents": "^1.0.2", 60 | "@typescript-eslint/eslint-plugin": "^4.10.0", 61 | "@typescript-eslint/parser": "^4.10.0", 62 | "cross-env": "^7.0.3", 63 | "eslint": "^7.16.0", 64 | "eslint-plugin-react": "^7.21.5", 65 | "node-fetch": "^3.2.3", 66 | "npm-watch": "^0.11.0", 67 | "rollup": "^2.56.3", 68 | "rollup-plugin-chrome-extension": "^3.6.1", 69 | "rollup-plugin-empty-dir": "^1.0.5", 70 | "rollup-plugin-zip": "^1.0.3", 71 | "tslib": "^2.0.3", 72 | "typescript": "^4.5.0", 73 | "webextension-polyfill": "^0.7.0" 74 | }, 75 | "prettier": { 76 | "semi": false, 77 | "tabWidth": 2, 78 | "singleQuote": true, 79 | "printWidth": 80 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/pages/Onboarding.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Center, 4 | Code, 5 | Flex, 6 | Heading, 7 | Kbd, 8 | Link, 9 | ListItem, 10 | OrderedList, 11 | Text, 12 | } from '@chakra-ui/react' 13 | import React from 'react' 14 | import ReactDOMClient from 'react-dom/client' 15 | import LinkRoamerLogo from '../components/LinkRoamerLogo' 16 | import ThemeProvider from '../providers/ThemeProvider' 17 | 18 | const Onboarding = () => ( 19 | 20 | 21 | 27 |
28 | 29 |
30 | 38 | Welcome to{' '} 39 | 40 | Link Roamer 41 | 42 | 43 | 44 | A few things to know before you get to roamin'. 45 | 46 | 47 | 48 | Link Roamer only works on http and https{' '} 49 | pages. 50 | 51 | 52 | The default keyboard shortcut to open Link Roamer is{' '} 53 | Cmd + U. Sometimes that shortcut is taken by another 54 | extension and you will need to pick a new one on the{' '} 55 | chrome://extensions/shortcuts settings page. 56 | 57 | 58 | This extension is open-source. If you encounter an issue, please{' '} 59 | 64 | submit an issue on GitHub 65 | {' '} 66 | or{' '} 67 | 72 | send me a message on Twitter 73 | 74 | . 75 | 76 | 77 |
78 |
79 |
80 | ) 81 | 82 | const root = document.getElementById('root')! 83 | ReactDOMClient.createRoot(root).render() 84 | -------------------------------------------------------------------------------- /src/components/LinkList/Domain.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AccordionButton, 3 | AccordionIcon, 4 | AccordionItem, 5 | AccordionPanel, 6 | Badge, 7 | Checkbox, 8 | Heading, 9 | HStack, 10 | List, 11 | Stack, 12 | } from '@chakra-ui/react' 13 | import React from 'react' 14 | import { useCheckedItems } from '../../providers/CheckedItems' 15 | import Link from '../../scripts/Link' 16 | import LinksHandler from '../../scripts/LinksHandler' 17 | import Favicon from '../Favicon' 18 | import QuantityTag from '../ResponseTag' 19 | import LinkItem from './LinkItem' 20 | 21 | type Props = { 22 | domain: string 23 | links: Link[] 24 | } 25 | 26 | const Domain = ({ domain, links }: Props) => { 27 | const { checkedItems, setCheckedItems } = useCheckedItems() 28 | const allChecked = links.every((link) => checkedItems.includes(link.href)) 29 | const isIndeterminate = 30 | links.some((link) => checkedItems.includes(link.href)) && !allChecked 31 | 32 | const handleChange = () => { 33 | const hrefs = links.map((link) => link.href) 34 | 35 | allChecked 36 | ? setCheckedItems((prev) => prev.filter((item) => !hrefs.includes(item))) 37 | : setCheckedItems((prev) => [...prev, ...hrefs]) 38 | } 39 | 40 | const lp = new LinksHandler(links) 41 | const httpQty = lp.httpLinks.length 42 | const brokenQty = lp.fourOhFourLinks.length 43 | 44 | return ( 45 | 46 |

47 | 48 | 49 | 56 | 57 | 58 | {domain} 59 | 60 | {links.length} 61 | 62 | 63 | {httpQty && ( 64 | 65 | HTTP 66 | 67 | )} 68 | {brokenQty && ( 69 | 70 | 404 71 | 72 | )} 73 | 74 | 75 | 76 |

77 | 78 | 79 | {links.map((link, index) => ( 80 | 81 | ))} 82 | 83 | 84 |
85 | ) 86 | } 87 | 88 | export default Domain 89 | -------------------------------------------------------------------------------- /src/components/Tabs/index.tsx: -------------------------------------------------------------------------------- 1 | import { TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import { useData } from '../../providers/DataProvider' 4 | import Link from '../../scripts/Link' 5 | import LinksHandler from '../../scripts/LinksHandler' 6 | import EmptyState from '../EmptyState' 7 | import LinkList from '../LinkList' 8 | import SearchFilter from './SearchFilter' 9 | import SelectAll from './SelectAll' 10 | import Tab from './Tab' 11 | 12 | const CategoryTabs = () => { 13 | const [links, setLinks] = useState([]) 14 | const [filter, setFilter] = useState('') 15 | const { data } = useData() 16 | 17 | useEffect(() => { 18 | setLinks(data.links) 19 | }, [data.links.length]) 20 | 21 | useEffect(() => { 22 | const filteredLinks = data.links.filter((link) => 23 | link.href.includes(filter) 24 | ) 25 | setLinks(filteredLinks) 26 | }, [filter]) 27 | 28 | const lp = new LinksHandler(links) 29 | 30 | const all = { 31 | links: LinksHandler.categorizeByDomain(links), 32 | quantity: links.length, 33 | } 34 | 35 | const fragments = { 36 | links: LinksHandler.categorizeByDomain(lp.fragmentLinks), 37 | quantity: lp.fragmentLinks.length, 38 | } 39 | 40 | const notOk = { 41 | links: LinksHandler.categorizeByDomain(lp.notOkLinks), 42 | quantity: lp.notOkLinks.length, 43 | } 44 | 45 | const redirected = { 46 | links: LinksHandler.categorizeByDomain(lp.redirectedLinks), 47 | quantity: lp.redirectedLinks.length, 48 | } 49 | 50 | return ( 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | {all.quantity < 1 ? ( 63 | 64 | ) : ( 65 | 66 | 67 | 68 | 69 | {notOk.quantity && ( 70 | 71 | 72 | 73 | )} 74 | {redirected.quantity && ( 75 | 76 | 77 | 78 | )} 79 | {fragments.quantity && ( 80 | 81 | 82 | 83 | )} 84 | 85 | )} 86 | 87 | 88 | ) 89 | } 90 | 91 | export default CategoryTabs 92 | -------------------------------------------------------------------------------- /src/components/ActionsBar/TabGroupPopover.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | ButtonGroup, 4 | FormLabel, 5 | Heading, 6 | Input, 7 | Popover, 8 | PopoverArrow, 9 | PopoverBody, 10 | PopoverCloseButton, 11 | PopoverContent, 12 | PopoverFooter, 13 | PopoverHeader, 14 | PopoverTrigger, 15 | Text, 16 | useDisclosure, 17 | VisuallyHidden, 18 | } from '@chakra-ui/react' 19 | import React from 'react' 20 | import ReactFocusLock from 'react-focus-lock' 21 | import { useCheckedItems } from '../../providers/CheckedItems' 22 | import c from '../../scripts/Chrome' 23 | import { NewTabIcon } from '../icons' 24 | 25 | const TabGroupPopover = () => { 26 | const { onOpen, onClose, isOpen } = useDisclosure() 27 | const { checkedItems } = useCheckedItems() 28 | const inputRef = React.useRef(null) 29 | 30 | async function createTabGroup() { 31 | const tabIds = await Promise.all( 32 | checkedItems.map(async (href) => { 33 | const tab = await c.createBackgroundTab(href) 34 | return tab.id as number 35 | }) 36 | ) 37 | 38 | const title = inputRef.current?.value ?? '' 39 | const groupId = await c.createTabGroup(tabIds) 40 | await c.updateTabGroup(groupId, title) 41 | onClose() 42 | } 43 | 44 | return ( 45 | 52 | {/*// @ts-ignore*/} 53 | 54 | 57 | 58 | 59 | 60 | 61 | Set a tab group title 62 | 63 | The title or tab group color can be changed later. 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Set a tab group title 72 | 73 | 74 | 75 | 76 | 83 | 84 | 85 | 92 | 93 | 94 | 95 | 96 | 97 | ) 98 | } 99 | 100 | export default TabGroupPopover 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 👋 Link Roamer 2 | 3 | ![Link Roamer Graphic!](./assets/graphic.png) 4 | 5 | --- 6 | 7 | ## 🚀 Where to install 8 | 9 | This extension is available on: 10 | 11 | - [Chrome Web store](https://chrome.google.com/webstore/detail/link-roamer/lgcgflalbmeodapiohjepkjlgipmhofe) 12 | - [Firefox Addon Marketplace](https://addons.mozilla.org/en-US/firefox/addon/link-roamer/) 13 | - [Microsoft Edge Add-ons Marketplace](https://microsoftedge.microsoft.com/addons/detail/link-roamer/bigambbapbnineapeagbdpdpkaildjdd) 14 | 15 | --- 16 | 17 | A browser extension for gathering, organizing, and inspecting all the links on a 18 | web page. It's also pretty good at finding broken links (404). This extension 19 | allows you to: 20 | 21 | ### Inspect links 22 | 23 | - Highlight broken links on a page (i.e. 404) 24 | - Highlight non-secure links on a page (i.e. http) 25 | - Quickly find, view, organize, and navigate links on a page 26 | - See the status and reasoning for requests that don't succed 27 | - View links and where they redirect to before navigating 28 | 29 | ### Organize links 30 | 31 | - Group links by their primary domain name 32 | - Create a tab group from a set of selected links (_\*Chrome only_) 33 | - Save a set of selected links as bookmarks 34 | 35 | ### Interact with links 36 | 37 | - Copy links to clipboard 38 | - Open links in a new window or tab 39 | - Open individual links in a background tab 40 | - Export links as `json`, `text`, or `csv` 41 | - Export detailed URL and fetch request data 42 | 43 | --- 44 | 45 | ![Link Roamer Graphic!](./assets/graphic-2.png) 46 | 47 | ## Local development 48 | 49 | Before making edits you will need to build the extension locally and side load 50 | it as a developer extension to test any changes. 51 | 52 | > At the moment, v3 and v2 manifest API conflicts are making things difficult. 53 | > The Rollup config programmatically compiles two different versions depending 54 | > on the manifest. For Chrome, the v3 manifest in the `dist` folder is the one 55 | > to load. For Firefox, you will need to build and load the zipped release 56 | > version with `yarn release`. 57 | 58 | ### 1. Clone the repo 59 | 60 | Clone the repo to your local machine and navigate into the root directory. 61 | 62 | ```shell 63 | cd link-roamer 64 | ``` 65 | 66 | ### 2. Install dependencies 67 | 68 | Link Roamer uses yarn to build the necessary dependencies. 69 | 70 | ```shell 71 | yarn 72 | ``` 73 | 74 | ### 3. Start and watch a build 75 | 76 | For development with automatic reloading: 77 | 78 | ```bash 79 | yarn start 80 | ``` 81 | 82 | This will build to the `dist` folder. To load the extension, open the Extensions 83 | Dashboard, enable "Developer mode", click "Load unpacked", and choose the 84 | `dist/v3-manifest` folder. 85 | 86 | When you make changes in src the background script and any content script will 87 | reload automatically. 88 | 89 | ### 4. Start the server 90 | 91 | You'll need to start up the server to make fetch calls and check statuses. To build content from the `src/api` folder, run: 92 | 93 | ```bash 94 | yarn watch 95 | ``` 96 | 97 | This will startup the server and restart it any time a change is recompiled from rollup. 98 | 99 | --- 100 | 101 | ## More apps by me 102 | 103 | I like making things. [Check out what I'm up to lately](https://rossmoody.com). 104 | 105 | --- 106 | 107 | ## Open source 108 | 109 | This extension is open source and doesn't collect any information from users. 110 | It's free, and made available because I enjoy making useful things for the web. 111 | 112 | Please consider contributing with an idea, bug fix, or feature request. 113 | 114 | --- 115 | 116 | ## Contribute 117 | 118 | Feel free to submit a pull request if you've made an improvement of some kind. 119 | This is an open source project and any help is very appreciated. 120 | -------------------------------------------------------------------------------- /src/scripts/Chrome.ts: -------------------------------------------------------------------------------- 1 | import CreateData = chrome.windows.CreateData 2 | import GroupOptions = chrome.tabs.GroupOptions 3 | import LinkStatus from '../api/LinkStatus' 4 | import { Message } from '../types' 5 | import Link from './Link' 6 | 7 | class Chrome { 8 | /** 9 | * Inject scripts into a given tabId. 10 | * The function returns the result of whatever function is passed in. 11 | */ 12 | async executeScript(tabId: number, func: () => Type) { 13 | if ('isV3Manifest') { 14 | return ( 15 | await chrome.scripting.executeScript({ 16 | target: { tabId }, 17 | func, 18 | }) 19 | )[0].result as Type 20 | } 21 | 22 | return ( 23 | await browser.tabs.executeScript(tabId, { 24 | code: `(${func})()`, 25 | }) 26 | )[0] as Type 27 | } 28 | 29 | async getActiveTab() { 30 | const config: chrome.tabs.QueryInfo = { active: true, currentWindow: true } 31 | 32 | if ('isV3Manifest') { 33 | return (await chrome.tabs.query(config))[0] 34 | } 35 | 36 | return (await browser.tabs.query(config))[0] 37 | } 38 | 39 | async createBackgroundTab(url: string) { 40 | const config: chrome.tabs.CreateProperties = { active: false, url } 41 | return await chrome.tabs.create(config) 42 | } 43 | 44 | async createTabsInNewWindow(url: string[]) { 45 | const config: CreateData = { url } 46 | return await chrome.windows.create(config) 47 | } 48 | 49 | /** 50 | * Creates a tab group from the list of given tabIds. 51 | * If no windowId, the tab group opens in currently active window. 52 | * Returns the id of the newly created tab group. 53 | */ 54 | async createTabGroup(tabIds: number[], windowId?: number) { 55 | const config: GroupOptions = { 56 | tabIds, 57 | createProperties: { windowId }, 58 | } 59 | 60 | return await chrome.tabs.group(config) 61 | } 62 | 63 | /** 64 | * Collapses a newly created tab group and sets the given title. 65 | */ 66 | async updateTabGroup(groupId: number, title: string) { 67 | const updateProperties: chrome.tabGroups.UpdateProperties = { 68 | collapsed: true, 69 | title, 70 | } 71 | return await chrome.tabGroups.update(groupId, updateProperties) 72 | } 73 | 74 | async createBookmarkFolder(title: string) { 75 | const config: chrome.bookmarks.BookmarkCreateArg = { 76 | title, 77 | } 78 | 79 | if ('isV3Manifest') return await chrome.bookmarks.create(config) 80 | return browser.bookmarks.create(config) 81 | } 82 | 83 | /** 84 | * Creates a bookmark and puts it inside the given folder using the parentId. 85 | */ 86 | async createBookmark(title: string, parentId: string, url: string) { 87 | const config: chrome.bookmarks.BookmarkCreateArg = { 88 | parentId, 89 | title, 90 | url, 91 | } 92 | if ('isV3Manifest') return await chrome.bookmarks.create(config) 93 | return browser.bookmarks.create(config) 94 | } 95 | 96 | /** 97 | * Sends a message to the background script to process all the given hrefs 98 | * and return the response status objects for each. 99 | */ 100 | async fetchLinks(links: Link[]): Promise { 101 | const data = JSON.stringify(links) 102 | 103 | const message: Message = { 104 | action: 'fetchLinks', 105 | data, 106 | } 107 | 108 | return new Promise((resolve) => { 109 | chrome.runtime.sendMessage(message, resolve) 110 | }) 111 | } 112 | 113 | async getStorage(key: string): Promise { 114 | const result = await chrome.storage.local.get(key) 115 | return result[key] 116 | } 117 | 118 | setStorage(key: string, value: unknown) { 119 | chrome.storage.local.set({ [key]: value }) 120 | } 121 | } 122 | 123 | export default new Chrome() 124 | -------------------------------------------------------------------------------- /src/components/LinkRoamerLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | type Props = { 4 | size: number 5 | } 6 | 7 | const LinkRoamerLogo = (props: Props) => ( 8 | 15 | 19 | 25 | 31 | 37 | 38 | 46 | 47 | 48 | 49 | 57 | 58 | 59 | 60 | 61 | 62 | ) 63 | 64 | export default LinkRoamerLogo 65 | -------------------------------------------------------------------------------- /src/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Center, Heading, Text } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | const EmptyState = () => ( 5 |
6 | 7 | 8 | 9 | 10 | 11 | No links to show 12 | 13 | 14 | Try different keywords in the search filter or on the off-chance you 15 | found a site with no links; congratulations, that doesn't happen 16 | very often. 17 | 18 | 19 |
20 | ) 21 | 22 | const EmptyStateGraphic = () => ( 23 | 30 | 31 | 35 | 36 | ) 37 | 38 | export default EmptyState 39 | -------------------------------------------------------------------------------- /src/status-codes.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 300: { 3 | title: 'Multiple Choice', 4 | description: 5 | 'The request has more than one possible response. The user agent or user should choose one of them.', 6 | }, 7 | 301: { 8 | title: 'Moved Permanently', 9 | description: 10 | 'The URL of the requested resource has been changed permanently.', 11 | }, 12 | 302: { 13 | title: 'Found', 14 | description: 15 | 'This response code means that the URI of requested resource has been changed temporarily.', 16 | }, 17 | 303: { 18 | title: 'See Other', 19 | description: 20 | 'The server sent this response to direct the client to get the requested resource at another URI with a GET request.', 21 | }, 22 | 307: { 23 | title: 'Use Proxy Deprecated', 24 | description: 25 | 'The server sends this response to direct the client to get the requested resource at another URI with same method that was used in the prior request.', 26 | }, 27 | 308: { 28 | title: 'Permanent Redirect', 29 | description: 30 | 'This means that the resource is now permanently located at another URI, specified by the Location: HTTP Response header.', 31 | }, 32 | 400: { 33 | title: 'Bad Request', 34 | description: 35 | 'The server cannot or will not process the request due to something that is perceived to be a client error.', 36 | }, 37 | 401: { 38 | title: 'Unauthorized', 39 | description: 40 | 'The client must authenticate itself to get the requested response.', 41 | }, 42 | 403: { 43 | title: 'Forbidden', 44 | description: 45 | 'The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource.', 46 | }, 47 | 404: { 48 | title: 'Not found', 49 | description: 50 | 'This URL may be broken. Link Roamer sent 5 requess and could not get a response. It is best to double check, as sometimes valid sites block requests.', 51 | }, 52 | 405: { 53 | title: 'Method Not Allowed', 54 | description: 55 | 'This is a valid URL but the request method used is not supported.', 56 | }, 57 | 406: { 58 | title: 'Not Acceptable', 59 | description: 60 | "This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content that conforms to the criteria given by the user agent.", 61 | }, 62 | 407: { 63 | title: 'Proxy Authentication Required', 64 | description: 65 | 'This is similar to 401 Unauthorized but authentication is needed to be done by a proxy.', 66 | }, 67 | 408: { 68 | title: 'Request Timeout', 69 | description: 70 | 'This response is sent on an idle connection by some servers, even without any previous request by the client.', 71 | }, 72 | 409: { 73 | title: 'Conflict', 74 | description: 'A request conflicts with the current state of the server.', 75 | }, 76 | 410: { 77 | title: 'Gone', 78 | description: 79 | 'This response is sent when the requested content has been permanently deleted from server, with no forwarding address.', 80 | }, 81 | 411: { 82 | title: 'Length Required', 83 | description: 84 | 'Server rejected the request because the Content-Length header field is not defined and the server requires it.', 85 | }, 86 | 412: { 87 | title: 'Precondition Failed', 88 | description: 89 | 'The client has indicated preconditions in its headers which the server does not meet.', 90 | }, 91 | 413: { 92 | title: 'Payload Too Large', 93 | description: 94 | 'Request entity is larger than limits defined by server. The server might close the connection or return an Retry-After header field.', 95 | }, 96 | 414: { 97 | title: 'URI Too Long', 98 | description: 99 | 'The URI requested by the client is longer than the server is willing to interpret.', 100 | }, 101 | 415: { 102 | title: 'Unsupported Media Type', 103 | description: 104 | 'The media format of the requested data is not supported by the server, so the server is rejecting the request.', 105 | }, 106 | 416: { 107 | title: 'Range Not Satisfiable', 108 | description: 109 | "The range specified by the Range header field in the request cannot be fulfilled. It's possible that the range is outside the size of the target URI's data.", 110 | }, 111 | 417: { 112 | title: 'Expectation Failed', 113 | description: 114 | 'This response code means the expectation indicated by the Expect request header field cannot be met by the server.', 115 | }, 116 | 418: { 117 | title: "I'm a teapot", 118 | description: 'The server refuses the attempt to brew coffee with a teapot.', 119 | }, 120 | 421: { 121 | title: 'Misdirected Request', 122 | description: 123 | 'The request was directed at a server that is not able to produce a response.', 124 | }, 125 | 422: { 126 | title: 'Unprocessable Entity (WebDAV)', 127 | description: 128 | 'The request was well-formed but was unable to be followed due to semantic errors.', 129 | }, 130 | 423: { 131 | title: '423 Locked (WebDAV)', 132 | description: 'The resource that is being accessed is locked.', 133 | }, 134 | 424: { 135 | title: 'Failed Dependency (WebDAV)', 136 | description: 'The request failed due to failure of a previous request.', 137 | }, 138 | 425: { 139 | title: 'Too Early Experimental', 140 | description: 141 | 'Indicates that the server is unwilling to risk processing a request that might be replayed.', 142 | }, 143 | 426: { 144 | title: 'Upgrade Required', 145 | description: 146 | 'The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol.', 147 | }, 148 | 428: { 149 | title: 'Precondition Required', 150 | description: 'The origin server requires the request to be conditional.', 151 | }, 152 | 429: { 153 | title: 'Too Many Requests', 154 | description: 155 | 'The user has sent too many requests in a given amount of time ("rate limiting").', 156 | }, 157 | 431: { 158 | title: 'Request Header Fields Too Large', 159 | description: 160 | 'The server is unwilling to process the request because its header fields are too large.', 161 | }, 162 | 451: { 163 | title: 'Unavailable For Legal Reasons', 164 | description: 165 | 'The user agent requested a resource that cannot legally be provided, such as a web page censored by a government.', 166 | }, 167 | 500: { 168 | title: 'Internal Server Error', 169 | description: 170 | 'The server has encountered a situation it does not know how to handle.', 171 | }, 172 | 501: { 173 | title: 'Not Implemented', 174 | description: 175 | 'The request method is not supported by the server and cannot be handled. The only methods that servers are required to support (and therefore that must not return this code) are GET and HEAD.', 176 | }, 177 | 502: { 178 | title: 'Bad Gateway', 179 | description: 180 | 'This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response.', 181 | }, 182 | 503: { 183 | title: 'Service Unavailable', 184 | description: 185 | 'The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded.', 186 | }, 187 | 504: { 188 | title: 'Gateway Timeout', 189 | description: 190 | 'This error response is given when the server is acting as a gateway and cannot get a response in time.', 191 | }, 192 | 505: { 193 | title: 'HTTP Version Not Supported', 194 | description: 195 | 'The HTTP version used in the request is not supported by the server.', 196 | }, 197 | 506: { 198 | title: 'Variant Also Negotiates', 199 | description: 200 | 'The server has an internal configuration error: the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process.', 201 | }, 202 | 507: { 203 | title: 'Insufficient Storage (WebDAV)', 204 | description: 205 | 'The method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request.', 206 | }, 207 | 508: { 208 | title: 'Loop Detected (WebDAV)', 209 | description: 210 | 'The server detected an infinite loop while processing the request.', 211 | }, 212 | 510: { 213 | title: 'Not Extended', 214 | description: 215 | 'Further extensions to the request are required for the server to fulfill it.', 216 | }, 217 | 511: { 218 | title: 'Network Authentication Required', 219 | description: 220 | 'Indicates that the client needs to authenticate to gain network access.', 221 | }, 222 | 999: { 223 | title: 'Custom Status', 224 | description: 225 | 'A non-standard code is returned by some sites (e.g. LinkedIn) which do not permit scanning.', 226 | }, 227 | } 228 | --------------------------------------------------------------------------------