Compare commits

...

101 Commits
master ... main

Author SHA1 Message Date
Shadowfacts 71203435d6 Don't update feeds when starting GenServer 2024-01-31 14:02:16 -05:00
Shadowfacts fd42f20920 Don't require content for items 2023-12-03 21:33:32 -05:00
Shadowfacts 60a2dcd73f Fix Verge extractor failing for features 2023-12-03 21:30:52 -05:00
Shadowfacts 3e6211c9ba Fix unused binding warnings 2023-12-03 21:28:24 -05:00
Shadowfacts f3e0c6b374 Update ecto 2023-08-05 15:10:29 -07:00
Shadowfacts fec640a37a Improve The Verge extractor 2023-07-12 22:11:31 -07:00
Shadowfacts b0089083db Fix inline script tags not being removed 2023-07-12 20:50:17 -07:00
Shadowfacts 54c6a1ca0a Remove extraneous <header> 2023-07-12 20:46:25 -07:00
Shadowfacts 2dda6d7f46 Add OIDC login 2023-06-25 15:19:11 -07:00
Shadowfacts 1f94e9080d Filter more things out of Slate and The Verge 2023-06-25 14:12:15 -07:00
Shadowfacts 6dd4f3ca82 Add ELB extractor 2023-06-25 14:06:18 -07:00
Shadowfacts 53cbe0a7e9 Update things, fix warnings 2023-06-25 14:03:16 -07:00
Shadowfacts 4e484cac92 Fix read/unread multiple endpoints not returning ids as strings 2023-01-31 15:07:02 -05:00
Shadowfacts 42ab728f7e Update feed_parser 2023-01-14 15:22:48 -05:00
Shadowfacts 86d7ffc7d9 Make regex filters case insensitive 2022-12-05 10:58:49 -05:00
Shadowfacts d7a37b5c64 Fix feed retry handler being handle_cast instead of handle_info 2022-10-24 09:57:54 -04:00
Shadowfacts ff3d9affe5 Fix error when parsing updating feeds without item guids 2022-09-19 10:02:11 -04:00
Shadowfacts 6f3d18a1ba Doh 2022-09-16 17:15:21 -04:00
Shadowfacts 52c6f1ff6c Limit number of items in initial sync 2022-09-16 16:39:08 -04:00
Shadowfacts 3129142274 Fix error when last sync is present but is invalid datetime 2022-09-16 16:35:38 -04:00
Shadowfacts 0e90ee527c Remove Sentry breadcrumbs from network requests
There ends up being a multi-hour gap between the breadcrumbs listed and
the error itself, so they're useless.
2022-09-14 18:13:09 -04:00
Shadowfacts 3ff6bc9518 Update Tesla/Mint 2022-09-14 18:12:07 -04:00
Shadowfacts e7184a2535 Add extractor for The Verge 2022-09-14 17:47:22 -04:00
Shadowfacts b29c75d7d6 Update to bootstrap 5 2022-09-13 22:25:29 -04:00
Shadowfacts 3046fc9b14 Replace leex templates with heex 2022-09-13 22:25:29 -04:00
Shadowfacts 052926791a Switch to esbuild
fuck webpack, all my homies hate webpack
2022-09-13 22:01:28 -04:00
Shadowfacts bf5ade1cc6 Update deps 2022-09-13 21:21:09 -04:00
Shadowfacts b9be2879ed Fix srcsets overriding rewritten image srcs 2022-07-17 15:13:13 -04:00
Shadowfacts 852db1520f Add birchtree.me extractor 2022-07-17 15:13:08 -04:00
Shadowfacts b443afcbf2 Update postgrex 2022-05-18 00:04:45 -04:00
Shadowfacts 4113599eca Update feed_parser 2022-04-18 18:26:56 -04:00
Shadowfacts 87ea1f5624 Ignore errors when there's no feed data 2022-04-18 18:20:46 -04:00
Shadowfacts a7a296b342 Exponential backoff retries for fetching feeds 2022-04-18 18:14:58 -04:00
Shadowfacts bbc729b5ca Handle gzipped http responses 2022-04-18 17:23:26 -04:00
Shadowfacts 85a83d30df Update feed_parser 2022-04-12 11:42:33 -04:00
Shadowfacts fb783b2097 Update feed_parser 2022-04-11 16:25:35 -04:00
Shadowfacts c614d6beff Add owner id to api token response 2022-03-06 22:09:40 -05:00
Shadowfacts 933a157278 Fix error when trying to change read status of tombstones 2022-01-31 14:31:16 -05:00
Shadowfacts f0299639e2 Daring Fireball: strip dd tag 2022-01-15 14:53:03 -05:00
Shadowfacts f70e358a80 Add items sync endpoint 2022-01-12 18:01:43 -05:00
Shadowfacts 7b9956a1aa Fix fervor item schema 2022-01-12 18:01:35 -05:00
Shadowfacts 306dea226c Fix oauth redirect not working 2021-12-24 15:50:42 -05:00
Shadowfacts 37a802b7a8 Don't put content from builtin extractor through readable_html twice 2021-11-06 12:01:23 -04:00
Shadowfacts d2d4651f1d Add Ars Technica extractor for multi-page articles 2021-11-06 12:00:35 -04:00
Shadowfacts e84ebc473a Add support for external readability implementation 2021-11-06 12:00:35 -04:00
Shadowfacts f1435611ef Switch fervor api to use string ids 2021-10-30 13:52:27 -04:00
Shadowfacts b81bd879d4 Switch from node-sass to dart sass 2021-10-30 13:46:38 -04:00
Shadowfacts e3ec1d6040
Fix missing clause in scrape stage 2021-10-22 16:20:50 -04:00
Shadowfacts b1c0ba3998
Switch to Mint 2021-10-22 16:17:27 -04:00
Shadowfacts 42f976f00f
Convert other item fields to text 2021-10-21 12:04:44 -04:00
Shadowfacts 59924ce8c8
Update feed_parser 2021-10-03 10:14:04 -04:00
Shadowfacts 281798a80b
Update Hackney/Certifi 2021-10-03 10:04:27 -04:00
Shadowfacts 42c8c2ad4f
Update readability 2021-09-22 21:09:11 -04:00
Shadowfacts e5036d24d0
Change item title column to text from string 2021-09-22 20:56:51 -04:00
Shadowfacts 5ece9cd21c
Yet more Sentry 2021-09-22 19:54:45 -04:00
Shadowfacts 6916647737
Don't try to convert data URIs to data URIs 2021-09-22 19:46:07 -04:00
Shadowfacts 1c2ef3bc51
More Sentry logging 2021-09-22 15:06:45 -04:00
Shadowfacts 64162fee92
Fix not handling non-200 HTTP codes when fetching feed 2021-09-22 14:41:29 -04:00
Shadowfacts fce1bf6c2f
Add Sentry 2021-09-22 13:59:44 -04:00
Shadowfacts 6e0271bf4b
Slate extractor: strip newsletter signup form 2021-09-19 22:32:10 -04:00
Shadowfacts d62d12262d
Fix current page changing when marking items as read 2021-09-15 10:38:46 -04:00
Shadowfacts 3b12f62379
Fix items w/o guids getting duplicated on every update 2021-09-12 19:55:19 -04:00
Shadowfacts 4d7843ee5f
Add force update feeds 2021-09-08 20:17:33 -04:00
Shadowfacts ddceb28803
Fix update feeds thinking all items already exist
Repo.exists? needs a query, not the keyword list
2021-09-08 20:10:19 -04:00
Shadowfacts a02ec174be
Use unique index to prevent duplicate items from being created 2021-09-08 09:35:46 -04:00
Shadowfacts cd36b40978
Fix double refreshing feeds 2021-09-08 09:35:37 -04:00
Shadowfacts bf98614de9
Update feed_parser 2021-09-08 09:23:26 -04:00
Shadowfacts 59fa3bc178
Allow configuring 512 pixels extractor 2021-09-07 20:32:35 -04:00
Shadowfacts 3ea3fdab45
Update feed_parser 2021-09-05 23:16:21 -04:00
Shadowfacts 162ba74dde
Extract authors from feeds 2021-09-03 17:09:16 -04:00
Shadowfacts 5990d0e4c2
Add Slate extractor 2021-09-03 17:09:10 -04:00
Shadowfacts fd75482779
Update plug_crypto 2021-09-03 16:04:09 -04:00
Shadowfacts 56d7579e2c
Update feed_parser to handle text/rss+xml 2021-08-29 19:26:57 -04:00
Shadowfacts 1d3ab00b95 Fix default rule mode 2021-08-28 18:08:21 -04:00
Shadowfacts a85dca5b3d
Add filtering by item content 2021-08-28 12:17:16 -04:00
Shadowfacts 71a23faa90
Add page titles 2021-08-28 11:58:22 -04:00
Shadowfacts 5c8baa2057
Generalize WP lazy-loading stripper 2021-03-31 20:19:01 -04:00
Shadowfacts 37dccdd4db
Tweak item table layout 2021-03-31 20:01:00 -04:00
Shadowfacts 25ed3f53d3
Add Read/Unread buttons to item tables 2021-03-31 20:00:44 -04:00
Shadowfacts 0593fcdb9a
Switch to hackney via Tesla 2021-03-31 19:33:19 -04:00
Shadowfacts 33d1cac5e1
Recover from errors in custom extractors 2021-03-31 15:30:17 -04:00
Shadowfacts 26b832b622
Fix whatever.scalzi.com extractor 2021-03-31 15:30:05 -04:00
Shadowfacts 0ded09a65d
Fix redirect handling not working with HTTPoison 2021-03-31 15:29:52 -04:00
Shadowfacts e10a614f3e
Switch back to HTTPoison 2021-03-31 14:43:59 -04:00
Shadowfacts 375406a8df
Limit Fever unread items API to 10k most recent 2020-10-24 14:05:04 -04:00
Shadowfacts 8e18a415eb
Fix error when attempting to convert image w/o Content-Type header to data URI 2020-10-24 13:37:06 -04:00
Shadowfacts 3bbf42df75
Fix error when sending never-updated feeds via Fever API 2020-10-24 13:32:00 -04:00
Shadowfacts a13b1d8181
Fix error rendering gemtext with preformatted blocks 2020-09-30 22:50:07 -04:00
Shadowfacts 1beff21fc5
Switch to Mojito for HTTP requests 2020-09-11 19:15:19 -04:00
Shadowfacts 533eae8680
Update plug_cowboy 2020-08-15 09:55:46 -04:00
Shadowfacts 04ffe0036d
Fix feed update failing on missing favicon 2020-08-14 21:56:38 -04:00
Shadowfacts bd42073e24
Fix whatever.scalzi.com extractor 2020-08-14 21:55:38 -04:00
Shadowfacts 5f3be52132
Add OPML tests 2020-07-19 11:40:14 -04:00
Shadowfacts dffd653738
Update gemini-ex 2020-07-19 11:32:59 -04:00
Shadowfacts 978ee86667
Use has_many through association for user feeds 2020-07-19 10:36:32 -04:00
Shadowfacts 93aebeb600
Tweak rule configuration UI 2020-07-19 10:08:53 -04:00
Shadowfacts ab105d71ae
Add Gemini document -> HTML converter stage 2020-07-18 23:13:42 -04:00
Shadowfacts 26bfb2e58f
Store item content MIME type 2020-07-18 23:13:24 -04:00
Shadowfacts 198ca0345b
Don't fetch favicon for Gemini protocol feeds 2020-07-18 22:13:51 -04:00
Shadowfacts 12bb742be9
Add Gemini protocol scrape stage 2020-07-18 19:50:41 -04:00
Shadowfacts 4f16933198
Add gemini protocol feed fetching 2020-07-18 19:27:53 -04:00
96 changed files with 3533 additions and 9505 deletions

56
assets/build.js Normal file
View File

@ -0,0 +1,56 @@
// const esbuild = require("esbuild");
import * as esbuild from "esbuild";
import {sassPlugin} from "esbuild-sass-plugin";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
const deploy = args.includes("--deploy");
const loader = {
".svg": "file",
".eot": "file",
".ttf": "file",
".woff": "file",
".otf": "file",
};
const plugins = [
sassPlugin(),
];
let opts = {
entryPoints: ["js/app.js"],
bundle: true,
target: "es2017",
outdir: "../priv/static/assets",
logLevel: "info",
loader,
plugins,
};
if (watch) {
opts = {
...opts,
watch,
sourcemap: "inline",
};
}
if (deploy) {
opts = {
...opts,
minify: true,
};
}
const promise = esbuild.build(opts);
if (watch) {
promise.then(result => {
process.stdin.on("close", () => {
process.exit(0);
});
process.stdin.resume();
});
}

View File

@ -83,15 +83,20 @@ label.sidebar-toggle > .oi {
.sidebar .nav-item a {
font-weight: 500;
color: #333;
transition: none;
}
.sidebar .nav-item a:hover {
text-decoration: none;
color: var(--blue);
color: var(--bs-link-color);
}
.sidebar .nav-link.active {
color: var(--blue);
color: var(--bs-link-color);
}
.sidebar .nav-item details summary a {
padding: 0;
}
.sidebar .nav-item details summary a:hover {
@ -141,6 +146,14 @@ label.sidebar-toggle > .oi {
display: inline-block;
}
.post-content img {
.item-content img {
max-width: 100%;
}
.item-content > .raw-content {
white-space: pre-wrap;
}
.item-table tr > .date {
min-width: 200px;
}

10062
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,16 @@
{
"scripts": {
"deploy": "webpack --mode production",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"bootstrap": "^4.5.0",
"jquery": "^3.5.1",
"nprogress": "^0.2.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"popper.js": "^1.16.1"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"babel-loader": "^8.0.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.13.1",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"sass-loader": "^8.0.2",
"terser-webpack-plugin": "^2.3.2",
"url-loader": "^4.1.0",
"webpack": "4.41.5",
"webpack-cli": "^3.3.2"
}
"type": "module",
"devDependencies": {
"esbuild": "^0.15.7",
"esbuild-sass-plugin": "^2.3.2"
},
"dependencies": {
"bootstrap": "5.2.1",
"jquery": "^3.6.1",
"nprogress": "^0.2.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"@popperjs/core": "^2.11.6"
}
}

View File

@ -1,58 +0,0 @@
const path = require('path');
const glob = require('glob');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = (env, options) => {
const devMode = options.mode !== 'production';
return {
optimization: {
minimizer: [
new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }),
new OptimizeCSSAssetsPlugin({})
]
},
entry: {
'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js'])
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../priv/static/js'),
publicPath: '/js/'
},
devtool: devMode ? 'source-map' : undefined,
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.[s]?css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader',
],
},
{
test: /\.(eot|otf|svg|ttf|woff)/,
use: {
loader: 'url-loader',
},
},
]
},
plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
]
}
};

View File

@ -5,7 +5,7 @@
# is restricted to this project.
# General application configuration
use Mix.Config
import Config
config :frenzy,
ecto_repos: [Frenzy.Repo]
@ -28,6 +28,24 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
config :logger, truncate: :infinity
config :frenzy, env: config_env()
config :frenzy, sentry_enabled: false
config :frenzy, external_readability: false
config :frenzy, oidc_enabled: false
config :ueberauth, Ueberauth,
providers: [
oidc:
{Ueberauth.Strategy.OIDC,
[
default: [
provider: :default_oidc
]
]}
]
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"
import_config "#{config_env()}.exs"

View File

@ -1,4 +1,4 @@
use Mix.Config
import Config
# For development, we disable any cache and enable
# debugging and code reloading.
@ -12,13 +12,7 @@ config :frenzy, FrenzyWeb.Endpoint,
code_reloader: true,
check_origin: false,
watchers: [
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch-stdin",
cd: Path.expand("../assets", __DIR__)
]
node: ["build.js", "--watch", cd: Path.expand("../assets", __DIR__)]
]
# ## SSL Support
@ -74,4 +68,6 @@ config :frenzy, Frenzy.Repo,
hostname: "localhost",
pool_size: 10
config :tesla, Tesla.Middleware.Logger, debug: false
import_config "dev.secret.exs"

View File

@ -1,4 +1,4 @@
use Mix.Config
import Config
# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information

View File

@ -1,4 +1,4 @@
use Mix.Config
import Config
# We don't run a server during test. If one is required,
# you can enable the server option below.

View File

@ -6,4 +6,12 @@ defmodule Frenzy do
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
def sentry_enabled? do
Application.get_env(:frenzy, :sentry_enabled)
end
def oidc_enabled? do
Application.get_env(:frenzy, :oidc_enabled)
end
end

View File

@ -16,9 +16,18 @@ defmodule Frenzy.Application do
FrenzyWeb.Endpoint,
# Starts a worker by calling: Frenzy.Worker.start_link(arg)
# {Frenzy.Worker, arg},
{Frenzy.UpdateFeeds, name: Frenzy.UpdateFeeds}
{Frenzy.UpdateFeeds, name: Frenzy.UpdateFeeds},
{Frenzy.BuiltinExtractor, name: Frenzy.BuiltinExtractor}
]
children =
if Frenzy.oidc_enabled?() do
children ++
[{OpenIDConnect.Worker, Application.get_env(:ueberauth, Ueberauth.Strategy.OIDC)}]
else
children
end
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Frenzy.Supervisor]

View File

@ -0,0 +1,77 @@
defmodule Frenzy.BuiltinExtractor do
use GenServer
alias Frenzy.Network
require Logger
@external_url Application.compile_env(:frenzy, :external_readability_url)
def start_link(state) do
GenServer.start_link(__MODULE__, :ok, state)
end
@spec article(String.t(), String.t()) :: Floki.html_tree()
def article(url, html) do
GenServer.call(__MODULE__, {:article, url, html})
end
def init(_state) do
use_external = Application.get_env(:frenzy, :external_readability)
use_external =
if use_external do
uri = URI.parse(@external_url)
uri = %URI{uri | path: "/status"}
uri = URI.to_string(uri)
case Network.http_get(uri) do
{:ok, %Tesla.Env{status: 200}} ->
true
_ ->
Logger.warning("Could not reach external readability for healthcheck, disabling")
false
end
else
false
end
{:ok, use_external}
end
def handle_call({:article, url, html}, _from, state) do
# the genserver state is a boolean telling us whether to use the external readability
if state do
uri = URI.parse(@external_url)
uri = %URI{uri | path: "/readability", query: URI.encode_query(url: url)}
uri = URI.to_string(uri)
Logger.debug("Sending external readability request: #{uri}")
case Network.http_post(uri, html, headers: [{"content-type", "text/html"}]) do
{:ok, %Tesla.Env{status: 200, body: body}} ->
{:ok, doc} = Floki.parse_document(body)
{:reply, doc, state}
{:ok, %Tesla.Env{status: status}} ->
Logger.error("External readability failed, got HTTP #{status}")
if Frenzy.sentry_enabled?() do
Sentry.capture_message("External readability failed, got HTTP #{status}")
end
{:reply, Readability.article(html), state}
{:error, reason} ->
Logger.error("External readability failed: #{inspect(reason)}")
if Frenzy.sentry_enabled?() do
Sentry.capture_message("External readability failed: #{inspect(reason)}")
end
{:reply, Readability.article(html), state}
end
else
{:reply, Readability.article(html), state}
end
end
end

View File

@ -11,21 +11,22 @@ defmodule Frenzy.Feed do
title: feed.title,
url: feed.feed_url,
site_url: feed.site_url,
last_updated_on_time: Timex.to_unix(feed.last_updated),
last_updated_on_time:
if(is_nil(feed.last_updated), do: 0, else: Timex.to_unix(feed.last_updated)),
is_spark: false
}
end
def to_fervor(feed) do
%{
id: feed.id,
id: feed.id |> Integer.to_string(),
title: feed.title,
url: feed.site_url,
feed_url: feed.feed_url,
service_url:
Application.get_env(:frenzy, :base_url) <> Routes.feed_path(Endpoint, :show, feed.id),
last_updated: DateTime.to_iso8601(feed.last_updated),
group_ids: [feed.group_id]
group_ids: [feed.group_id |> Integer.to_string()]
}
end

View File

@ -20,9 +20,9 @@ defmodule Frenzy.Group do
def to_fervor(group) do
%{
id: group.id,
id: group.id |> Integer.to_string(),
title: group.title,
feed_ids: group.feeds |> Enum.map(fn f -> f.id end),
feed_ids: group.feeds |> Enum.map(&Integer.to_string(&1.id)),
service_url:
Application.get_env(:frenzy, :base_url) <> Routes.group_path(Endpoint, :show, group.id)
}

View File

@ -1,46 +0,0 @@
defmodule Frenzy.HTTP do
require Logger
@redirect_codes [301, 302]
def get(url, opts \\ []) do
case HTTPoison.get(url, opts) do
{:ok, %HTTPoison.Response{status_code: 200} = response} ->
{:ok, response}
{:ok, %HTTPoison.Response{status_code: status_code, headers: headers}}
when status_code in @redirect_codes ->
headers
|> Enum.find(fn {name, _value} -> name == "Location" end)
|> case do
{"Location", new_url} ->
new_url =
case URI.parse(new_url) do
%URI{host: nil, path: path} ->
# relative path
%URI{URI.parse(url) | path: path} |> URI.to_string()
uri ->
uri
end
Logger.debug("Got 301 redirect from #{url} to #{new_url}")
get(new_url, opts)
_ ->
{:error, "Missing Location header for redirect"}
end
{:ok, %HTTPoison.Response{status_code: 403}} ->
{:error, "403 Forbidden"}
{:ok, %HTTPoison.Response{status_code: 404}} ->
{:error, "404 Not Found"}
{:ok, %HTTPoison.Response{status_code: status_code}} ->
{:error, "HTTP #{status_code}"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
end

View File

@ -1,6 +1,7 @@
defmodule Frenzy.Item do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias FrenzyWeb.Router.Helpers, as: Routes
alias FrenzyWeb.Endpoint
@ -25,8 +26,8 @@ defmodule Frenzy.Item do
def to_fervor(item) do
res = %{
id: item.id,
feed_id: item.feed_id,
id: item.id |> Integer.to_string(),
feed_id: item.feed_id |> Integer.to_string(),
title: item.title,
author: item.creator,
content: item.content,
@ -37,14 +38,15 @@ defmodule Frenzy.Item do
}
if item.date do
Map.put(res, :created_at, DateTime.to_iso8601(item.date))
Map.put(res, :published, DateTime.to_iso8601(item.date))
else
res
end
end
schema "items" do
field :content, :string
field :content, :string, default: ""
field :content_type, :string
field :date, :utc_datetime
field :creator, :string
field :guid, :string
@ -63,6 +65,7 @@ defmodule Frenzy.Item do
__meta__: Ecto.Schema.Metadata.t(),
id: integer() | nil,
content: String.t(),
content_type: String.t() | nil,
date: DateTime.t(),
creator: String.t(),
guid: String.t(),
@ -79,7 +82,23 @@ defmodule Frenzy.Item do
@doc false
def changeset(item, attrs) do
item
|> cast(attrs, [:guid, :title, :url, :creator, :date, :content, :read, :read_date, :tombstone])
|> validate_required([:guid, :url, :date, :content, :feed])
|> cast(attrs, [
:guid,
:title,
:url,
:creator,
:date,
:content,
:read,
:read_date,
:tombstone,
:feed_id
])
|> validate_required([:guid, :url, :date, :feed_id])
|> unique_constraint([:feed_id, :guid], name: :items_feed_guid_index)
end
def exists?(feed_id, guid) do
Frenzy.Repo.exists?(from i in __MODULE__, where: i.feed_id == ^feed_id and i.guid == ^guid)
end
end

101
lib/frenzy/network.ex Normal file
View File

@ -0,0 +1,101 @@
defmodule Frenzy.Network do
require Logger
defmodule HTTP do
use Tesla
adapter(Tesla.Adapter.Mint)
plug Tesla.Middleware.Logger, log_level: &log_level/1
plug Tesla.Middleware.FollowRedirects
plug Tesla.Middleware.Compression
# can't use JSON middleware currently, because feed_parser expects to parse the raw body data itself
# plug Tesla.Middleware.JSON
plug Tesla.Middleware.Timeout, timeout: 10_000
def log_level(env) do
case env.status do
code when code >= 400 -> :warn
_ -> :debug
end
end
end
@spec http_get(String.t()) :: Tesla.Env.result()
def http_get(url) do
HTTP.get(url)
end
@spec http_post(String.t(), Tesla.Env.body(), [Tesla.option()]) :: Tesla.Env.result()
def http_post(url, body, options \\ []) do
HTTP.post(url, body, options)
end
# @http_redirect_codes [301, 302]
# @spec http_get(String.t()) :: {:ok, HTTPoison.Response.t()} | {:error, term()}
# def http_get(url) do
# case HTTPoison.get(url) do
# {:ok, %HTTPoison.Response{status_code: 200} = response} ->
# {:ok, response}
# {:ok, %HTTPoison.Response{status_code: status_code, headers: headers}}
# when status_code in @http_redirect_codes ->
# headers
# |> Enum.find(fn {name, _value} -> String.downcase(name) == "location" end)
# |> case do
# {_, new_url} ->
# new_url =
# case URI.parse(new_url) do
# %URI{host: nil, path: path} ->
# # relative path
# %URI{URI.parse(url) | path: path} |> URI.to_string()
# uri ->
# uri
# end
# Logger.debug("Got 301 redirect from #{url} to #{new_url}")
# http_get(new_url)
# _ ->
# {:error, "Missing Location header for redirect"}
# end
# {:ok, %HTTPoison.Response{status_code: 403}} ->
# {:error, "403 Forbidden"}
# {:ok, %HTTPoison.Response{status_code: 404}} ->
# {:error, "404 Not Found"}
# {:ok, %HTTPoison.Response{status_code: status_code}} ->
# {:error, "HTTP #{status_code}"}
# {:error, error} ->
# {:error, error}
# end
# end
@gemini_success_codes 20..29
@gemini_redirect_codes 30..39
@spec gemini_request(String.t() | URI.t()) :: {:ok, Gemini.Response.t()} | {:error, term()}
def gemini_request(uri) do
case Gemini.request(uri) do
{:ok, %Gemini.Response{status: code} = response} when code in @gemini_success_codes ->
{:ok, response}
{:ok, %Gemini.Response{status: code, meta: new_url}}
when code in @gemini_redirect_codes ->
gemini_request(URI.merge(uri, new_url))
{:ok, %Gemini.Response{status: code}} ->
{:error, "Unhandled Gemini status code: #{code}"}
{:error, reason} ->
{:error, reason}
end
end
end

View File

@ -23,7 +23,7 @@ defmodule Frenzy.OPML.Importer do
outline_elements
|> Enum.flat_map(&get_feeds/1)
|> Enum.reduce(%{}, fn {group, feed_url}, acc ->
Map.update(acc, group, [], fn feeds -> [feed_url | feeds] end)
Map.update(acc, group, [feed_url], fn feeds -> [feed_url | feeds] end)
end)
end

View File

@ -17,7 +17,7 @@ defmodule Frenzy.Pipeline.ConditionalStage do
@impl Stage
def apply(opts, item_params) do
Logger.warn("Received invalid conditional opts: #{inspect(opts)}")
Logger.warning("Received invalid conditional opts: #{inspect(opts)}")
{:ok, item_params}
end
@ -30,7 +30,7 @@ defmodule Frenzy.Pipeline.ConditionalStage do
end
defp test_condition(condition, _item_params) do
Logger.warn("Received invalid condition: #{inspect(condition)}")
Logger.warning("Received invalid condition: #{inspect(condition)}")
false
end

View File

@ -0,0 +1,75 @@
defmodule Frenzy.Pipeline.Extractor.ArsTechnica do
@moduledoc """
Extractor for https://arstechnica.com
Handles multi-page articles
"""
require Logger
alias Frenzy.Network
alias Frenzy.Pipeline.Extractor
@behaviour Extractor
@impl Extractor
def extract(html_tree) do
case get_pages_from_tree(html_tree) do
{:error, _} = err -> err
content -> {:ok, content}
end
end
defp get_pages_from_tree(tree) do
with [article | _] <- Floki.find(tree, ~s([itemtype="http://schema.org/NewsArticle"])),
[content | _] <- Floki.find(article, ~s([itemprop=articleBody])) do
content = clean_content(content)
next_page_url =
with [next | _] <- Floki.find(article, ".page-numbers a:last-of-type"),
"Next" <> _ <- Floki.text(next),
[href] <- Floki.attribute(next, "href") do
href
else
_ ->
nil
end
if next_page_url != nil do
with body when not is_nil(body) <- fetch_page(next_page_url),
{:ok, doc} <- Floki.parse_document(body),
next_pages when is_list(next_pages) <- get_pages_from_tree(doc) do
[content] ++ next_pages
else
_ ->
[
content,
{"p", [], [{"em", [], ["Article truncated, unable to scrape subsequent pages"]}]}
]
end
else
[content]
end
else
_ -> {:error, "no matching elements"}
end
end
defp clean_content(tree) do
Floki.filter_out(tree, ".social-left, .story-sidebar, .ad_wrapper, figcaption .enlarge-link")
end
defp fetch_page(url) do
Logger.debug("Getting Ars Technica page from #{url}")
case Network.http_get(url) do
{:ok, %Tesla.Env{status: code, body: body}} when code in 200..299 ->
body
{:ok, %Tesla.Env{status: code}} ->
Logger.warning("Unexpected HTTP code #{code} getting Ars Technica page #{url}")
nil
{:error, reason} ->
Logger.error("Couldn't get Ars Technica page #{url}: #{inspect(reason)}")
nil
end
end
end

View File

@ -0,0 +1,19 @@
defmodule Frenzy.Pipeline.Extractor.Birchtree do
@moduledoc """
Extractor for https://birchtree.me
"""
alias Frenzy.Pipeline.Extractor
@behaviour Extractor
@impl Extractor
def extract(html_tree) do
case Floki.find(html_tree, "section.post-content") do
[content_elem | _] ->
{:ok, content_elem}
_ ->
{:error, "no matching elements"}
end
end
end

View File

@ -30,8 +30,8 @@ defmodule Frenzy.Pipeline.Extractor.DaringFireball do
defp get_link_element(html_tree) do
case Floki.find(html_tree, "dl.linkedlist dd") do
[dd_elem | _] ->
dd_elem
[{_, _, dd_children} | _] ->
dd_children
_ ->
nil

View File

@ -0,0 +1,20 @@
defmodule Frenzy.Pipeline.Extractor.ElectionLawBlog do
@moduledoc """
Extractor for https://electionlawblog.org
"""
alias Frenzy.Pipeline.Extractor
@behaviour Extractor
@impl Extractor
def extract(html_tree) do
case Floki.find(html_tree, "div.entry-content") do
[content_elem | _] ->
filtered = Floki.filter_out(content_elem, ".addtoany_share_save_container")
{:ok, filtered}
_ ->
{:error, "no matching elements"}
end
end
end

View File

@ -15,7 +15,7 @@ defmodule Frenzy.Pipeline.Extractor.MacStories do
# some images have full size links, strip those out
|> Floki.filter_out("a.view-full-size")
# rewrite non-standard images captions to <figure>/<figcaption>
|> Floki.map(&rewrite_element/1)
|> Floki.find_and_update("div, p", &rewrite_element/1)
{:ok, content_elem}

View File

@ -19,5 +19,6 @@ defmodule Frenzy.Pipeline.Extractor.OmMalik do
_ ->
{:error, "no matching elements"}
end
|> Extractor.Util.strip_wp_lazy_loading()
end
end

View File

@ -0,0 +1,43 @@
defmodule Frenzy.Pipeline.Extractor.Slate do
@moduledoc """
Extractor for https://slate.com
"""
alias Frenzy.Pipeline.Extractor
@behaviour Extractor
@impl Extractor
def extract(html_tree) do
case get_article_content(html_tree) do
nil ->
{:error, "no matching elements"}
elem ->
{:ok, elem}
end
end
defp get_article_content(html_tree) do
case Floki.find(html_tree, ".article__content") do
[el] ->
article_content =
Floki.filter_out(
el,
".slate-ad, .in-article-recirc, .social-share, .newsletter-signup, .recirc-line, .product"
)
image = Floki.find(html_tree, ".article__top-image img")
case image do
[] ->
article_content
[image | _] ->
[image, article_content]
end
_ ->
nil
end
end
end

View File

@ -0,0 +1,28 @@
defmodule Frenzy.Pipeline.Extractor.Util do
@doc """
WordPress Jetpack uses a 1x1 pixel transparent gif in a srcset to keep browsers from loading images
by overriding the src attribute. We want to strip those so the images actually load.
"""
@spec strip_wp_lazy_loading(Floki.html_tree()) :: Floki.html_tree()
def strip_wp_lazy_loading(tree) do
Floki.find_and_update(tree, "img.jetpack-lazy-image", fn
{"img", attrs} = el ->
class = Enum.find(attrs, fn {k, _} -> k == "class" end)
if !is_nil(class) && String.contains?(elem(class, 1), "jetpack-lazy-image") do
{
"img",
Enum.filter(attrs, fn
{"srcset", _} -> false
_ -> true
end)
}
else
el
end
el ->
el
end)
end
end

View File

@ -0,0 +1,112 @@
defmodule Frenzy.Pipeline.Extractor.TheVerge do
@moduledoc """
Extractor for https://theverge.com
Handles their bizarro new layout that's a pile of unsemantic classes
"""
require Logger
alias Frenzy.Pipeline.Extractor
@behaviour Extractor
@impl Extractor
def extract(html_tree) do
image = extract_header_image(html_tree)
content =
html_tree
|> Floki.find("main article > div:not(.duet--article--lede)")
|> Floki.filter_out(
".hidden, .duet--layout--rail, .duet--article--article-pullquote, .duet--article--comments-join-the-conversation, .duet--recirculation--related-list, .duet--article--comments-button, .duet--article--share-buttons"
)
|> Readability.Helper.remove_attrs("style")
|> Floki.traverse_and_update(&rewrite/1)
{:ok, image ++ content}
end
@spec extract_header_image(Floki.html_tree()) :: Floki.html_tree()
defp extract_header_image(html_tree) do
case Floki.find(html_tree, "article#content > .duet--article--lede figure") do
[figure | _] ->
img =
case Floki.find(figure, "img") do
[img | _] ->
[img]
_ ->
nil
end
caption =
case Floki.find(figure, ".duet--media--caption") do
[{_tag, _attrs, children} | _] ->
[{"figcaption", [], [Floki.text(children)]}]
_ ->
[]
end
if img do
[{"figure", [], img ++ caption}]
else
[]
end
_ ->
[]
end
end
defp rewrite({_tag, _attrs, children} = el) do
cond do
is_empty_gif(el) ->
nil
is_gallery(el) ->
images =
Floki.find(children, ":not(noscript) > img")
|> Enum.map(fn el ->
[src] = Floki.attribute(el, "src")
src
end)
{
"div",
[
{"style",
"display: flex; flex-direction: row; overflow-x: auto; scroll-snap-type: x mandatory;;"}
],
Enum.map(images, fn src ->
{
"img",
[
{"src", src},
{"loading", "lazy"},
{"style", "max-width: 85%; max-height: 30vh; scroll-snap-align: start;"}
],
[]
}
end)
}
true ->
el
end
end
defp rewrite(other), do: other
defp is_gallery(el) do
case Floki.attribute(el, "class") do
[classes] -> String.contains?(classes, "duet--article--gallery")
_ -> false
end
end
defp is_empty_gif(el) do
case Floki.attribute(el, "src") do
[src] -> String.starts_with?(src, "data:image/gif;")
_ -> false
end
end
end

View File

@ -18,10 +18,36 @@ defmodule Frenzy.Pipeline.Extractor.WhateverScalzi do
end
defp get_article_content(html_tree) do
case Floki.find(html_tree, "article.post > div.entry-content") do
[content_elem | _] ->
# remove social media buttons that are included in the .entry-content element
Floki.filter_out(content_elem, "div#jp-post-flair")
# there's no element that contains only the post content
# .postarea contains the headline, post content, social media buttons, and comments
case Floki.find(html_tree, ".postarea") do
[{_tag, _attrs, postarea_children}] ->
Enum.split_while(postarea_children, fn
{"h1", _, _} -> true
_ -> false
end)
|> case do
{_before_headline, [_headline | rest]} ->
{article_content, _rest} =
Enum.split_while(rest, fn
{"div", attrs, _} = el ->
class = Floki.attribute(el, "class") |> List.first()
if {"id", "comments"} in attrs do
false
else
is_nil(class) || !String.contains?(class, "sharedaddy")
end
_ ->
true
end)
Extractor.Util.strip_wp_lazy_loading(article_content)
_ ->
nil
end
_ ->
nil

View File

@ -45,18 +45,24 @@ defmodule Frenzy.Pipeline.FilterEngine do
def validate_rules(_rules), do: {:error, "rules must be a list"}
@rule_modes ~W[contains_string contains_string_case_sensitive matches_regex]
@rule_properties ~W[url title author content]
def validate_rule(rule) do
cond do
not is_map(rule) ->
{:error, "rule must be a map"}
not (Map.has_key?(rule, "mode") and is_binary(rule["mode"]) and
rule["mode"] in ["contains_string", "matches_regex"]) ->
{:error, "mode property must be a string, either 'contains_string' or 'matches_regex'"}
rule["mode"] in @rule_modes) ->
rule_modes_text = Enum.map_join(@rule_modes, ", ", &"'#{&1}'")
{:error, "mode property must be a string, one of #{rule_modes_text}"}
not (Map.has_key?(rule, "property") and is_binary(rule["property"]) and
rule["property"] in ["url", "title", "author"]) ->
{:error, "property property must be a string, either 'url', 'title', or 'author'"}
rule["property"] in @rule_properties) ->
rule_props_text = Enum.map_join(@rule_properties, ", ", &"'#{&1}'")
{:error, "property property must be a string, one of #{rule_props_text}"}
not (Map.has_key?(rule, "param") and is_binary(rule["param"])) ->
{:error, "param property must be a string"}
@ -105,16 +111,31 @@ defmodule Frenzy.Pipeline.FilterEngine do
end
defp matches(value, "contains_string", param) do
String.contains?(String.downcase(value), String.downcase(param))
end
defp matches(value, "contains_string_case_sensitive", param) do
String.contains?(value, param)
end
defp matches(value, "matches_regex", param) do
{:ok, regex} = Regex.compile(param)
{:ok, regex} = Regex.compile(param, "i")
String.match?(value, regex)
end
defp get_property(item_params, "url"), do: item_params.url
defp get_property(item_params, "title"), do: item_params.title
defp get_property(item_params, "author"), do: item_params.author
defp get_property(%{content: content, content_type: type}, "content")
when type in ["text/plain", "text/gemini"],
do: content
defp get_property(%{content: content, content_type: "text/html"}, "content") do
content
|> Floki.parse_fragment()
|> Floki.text()
end
defp get_property(_item_params, _property), do: {:error, "invalid property"}
end

View File

@ -0,0 +1,44 @@
defmodule Frenzy.Pipeline.GeminiScrapeStage do
require Logger
alias Frenzy.Network
alias Frenzy.Pipeline.Stage
@behaviour Stage
@impl Stage
def apply(opts, %{url: url} = item_params) do
case get_content(url, opts) do
{:error, reason} ->
Logger.warning("Unable to get Gemini content for #{url}: #{reason}")
{:ok, item_params}
{content, content_type} ->
{:ok, %{item_params | content: content, content_type: content_type}}
end
end
@impl Stage
def validate_opts(opts) do
{:ok, opts}
end
@impl Stage
def default_opts(), do: %{}
@spec get_content(String.t(), map()) :: {String.t(), String.t()} | {:error, term()}
def get_content(url, _opts) do
case Network.gemini_request(url) do
{:error, reason} ->
{:error, reason}
{:ok, %Gemini.Response{body: body, meta: meta}} ->
{body, parse_content_type(meta)}
end
end
defp parse_content_type(meta) do
meta
|> String.split(";")
|> hd()
|> String.trim()
end
end

View File

@ -0,0 +1,105 @@
defmodule Frenzy.Pipeline.RenderGeminiStage do
require Logger
alias Frenzy.Pipeline.Stage
@behaviour Stage
@impl Stage
def apply(_opts, %{content: content, content_type: "text/gemini"} = item_params) do
html = render_gemini(content)
{:ok, %{item_params | content_type: "text/html", content: html}}
end
def apply(_opts, %{content_type: content_type} = item_params) do
Logger.debug("Not rendering Gemini text for item, incorect content type: #{content_type}")
{:ok, item_params}
end
@impl Stage
def validate_opts(opts) do
{:ok, opts}
end
@impl Stage
def default_opts(), do: %{}
def render_gemini(gemini_source) do
gemini_source
|> Gemini.parse()
|> render_lines()
|> Floki.raw_html()
end
@spec render_lines([Gemini.line()], [String.t()]) :: [String.t()]
defp render_lines(lines, acc \\ [])
defp render_lines([], acc) do
Enum.reverse(acc)
end
defp render_lines([{:text, text} | rest], acc) do
render_lines(rest, [{"p", [], [text]} | acc])
end
defp render_lines([{:link, uri, text} | rest], acc) do
uri_str = URI.to_string(uri)
text = if is_nil(text), do: uri_str, else: text
a = {"a", [{"href", uri_str}], [text]}
p = {"p", [], [a]}
render_lines(rest, [p | acc])
end
defp render_lines([{:preformatted_start, _alt} | rest], acc) do
{preformatted_lines, [:preformatted_end | rest]} =
Enum.split_while(rest, fn
{:preformatted, _} -> true
_ -> false
end)
pre_text =
preformatted_lines
|> Enum.map(fn {:preformatted, text} -> text end)
|> Enum.join("\n")
pre = {"pre", [], pre_text}
render_lines(rest, [pre | acc])
end
defp render_lines([{:heading, text, level} | rest], acc) do
tag = "h#{level}"
heading = {tag, [], [text]}
render_lines(rest, [heading | acc])
end
defp render_lines([{:list_item, _text} | _rest] = lines, acc) do
{list_items, rest} =
Enum.split_while(lines, fn
{:list_item, _} -> true
_ -> false
end)
lis =
Enum.map(list_items, fn {:list_item, text} ->
{"li", [], [text]}
end)
ul = {"ul", [], lis}
render_lines(rest, [ul | acc])
end
defp render_lines([{:quoted, _text} | _rest] = lines, acc) do
{quoted_lines, rest} =
Enum.split_while(lines, fn
{:quoted, _} -> true
_ -> false
end)
ps =
Enum.map(quoted_lines, fn {:quoted, text} ->
{"p", [], [text]}
end)
blockquote = {"blockquote", [], ps}
render_lines(rest, [blockquote | acc])
end
end

View File

@ -1,6 +1,7 @@
defmodule Frenzy.Pipeline.ScrapeStage do
require Logger
alias Frenzy.HTTP
alias Frenzy.Network
alias Frenzy.BuiltinExtractor
alias Frenzy.Pipeline.Stage
@behaviour Stage
@ -11,7 +12,7 @@ defmodule Frenzy.Pipeline.ScrapeStage do
{:ok, %{item_params | content: content}}
{:error, reason} ->
Logger.warn("Unable to get article content for #{url}: #{reason}")
Logger.warning("Unable to get article content for #{url}: #{reason}")
{:ok, item_params}
end
end
@ -68,11 +69,14 @@ defmodule Frenzy.Pipeline.ScrapeStage do
Logger.debug("Getting article from #{url}")
url
|> HTTP.get()
|> Network.http_get()
|> case do
{:ok, response} ->
{:ok, %Tesla.Env{status: code} = response} when code in 200..299 ->
handle_response(url, response, opts)
{:ok, %Tesla.Env{status: code}} ->
{:error, "Unexpected HTTP code #{code}"}
{:error, reason} ->
{:error, "Couldn't scrape article: #{reason}"}
end
@ -80,16 +84,47 @@ defmodule Frenzy.Pipeline.ScrapeStage do
defp get_article_content(_url, _opts), do: {:error, "URL must be a non-empty string"}
@spec handle_response(String.t(), HTTPoison.Response.t(), map()) ::
@spec handle_response(String.t(), Tesla.Env.t(), map()) ::
{:ok, String.t()} | {:error, String.t()}
defp handle_response(url, %HTTPoison.Response{body: body}, opts) do
defp handle_response(url, %Tesla.Env{body: body}, opts) do
case opts["extractor"] do
"builtin" ->
{:ok, Readability.article(body)}
{:ok, BuiltinExtractor.article(url, body)}
module_name ->
html_tree = Floki.parse(body)
apply(String.to_existing_atom("Elixir." <> module_name), :extract, [html_tree])
{:ok, html_tree} = Floki.parse_document(body)
try do
apply(String.to_existing_atom("Elixir." <> module_name), :extract, [html_tree])
|> case do
{:ok, content} ->
# non-builtin extractors go through readable_html to cleanup any bad/untrusted html
# this is what Floki.readable_html without turning back into a string
content =
Readability.Helper.remove_attrs(content, Readability.regexes(:protect_attrs))
{:ok, content}
err ->
err
end
rescue
e ->
Logger.error(
"Encountered error extracting article content from '#{url}' with #{module_name}, falling back to default"
)
Logger.error(Exception.format(:error, e, __STACKTRACE__))
if Frenzy.sentry_enabled?() do
Sentry.capture_exception(e,
stacktrace: __STACKTRACE__,
extra: %{extractor: module_name, item_url: url}
)
end
{:ok, BuiltinExtractor.article(url, body)}
end
end
|> case do
{:ok, html} ->
@ -99,15 +134,15 @@ defmodule Frenzy.Pipeline.ScrapeStage do
value -> value
end
html = Floki.map(html, rewrite_image_urls(convert_to_data_uris, URI.parse(url)))
html =
html
|> Floki.filter_out("script")
|> Floki.find_and_update(
"img",
rewrite_image_urls(convert_to_data_uris, URI.parse(url))
)
case opts["extractor"] do
"builtin" ->
{:ok, Readability.readable_html(html)}
_ ->
{:ok, Floki.raw_html(html)}
end
{:ok, Floki.raw_html(html)}
res ->
res
@ -129,6 +164,16 @@ defmodule Frenzy.Pipeline.ScrapeStage do
attr
end)
has_src = Enum.find(new_attrs, fn {name, _} -> name == "src" end)
# remove srcsets because our transformation only applies to the src attribute, so that should always be used
new_attrs =
if has_src do
Enum.reject(new_attrs, fn {name, _} -> name == "srcset" end)
else
new_attrs
end
{"img", new_attrs}
elem ->
@ -139,18 +184,22 @@ defmodule Frenzy.Pipeline.ScrapeStage do
@content_type_allowlist ["image/jpeg", "image/png", "image/heic", "image/heif", "image/tiff"]
# convert images to data URIs so that they're stored by clients as part of the body
defp image_to_data_uri("data:" <> _ = src, _site_uri, _convert) do
src
end
defp image_to_data_uri(src, site_uri, true) do
absolute_url = URI.merge(site_uri, src) |> to_string()
case HTTP.get(absolute_url) do
{:ok, %HTTPoison.Response{body: body, headers: headers}} ->
{"Content-Type", content_type} =
Enum.find(headers, fn {header, _value} -> header == "Content-Type" end)
case Network.http_get(absolute_url) do
{:ok, %Tesla.Env{body: body, headers: headers}} ->
Enum.find(headers, fn {header, _value} -> String.downcase(header) == "content-type" end)
|> case do
{_, content_type} when content_type in @content_type_allowlist ->
"data:#{content_type};base64,#{Base.encode64(body)}"
if content_type in @content_type_allowlist do
"data:#{content_type};base64,#{Base.encode64(body)}"
else
src
_ ->
src
end
_ ->
@ -158,5 +207,5 @@ defmodule Frenzy.Pipeline.ScrapeStage do
end
end
defp image_to_data_uri(src, _site_uri, false), do: src
defp image_to_data_uri(src, site_uri, false), do: to_string(URI.merge(site_uri, src))
end

View File

@ -1,16 +1,18 @@
defmodule Frenzy.Task.CreateItem do
require Logger
use Task
alias Frenzy.Repo
alias Frenzy.{Repo, Item}
@spec start_link(Frenzy.Feed.t(), FeedParser.Item.t()) :: {:ok, pid()}
def start_link(feed, entry) do
Task.start_link(__MODULE__, :run, [feed, entry])
end
@spec run(Frenzy.Feed.t(), FeedParser.Item.t()) :: :ok
def run(feed, entry) do
Logger.metadata(item_task_id: generate_task_id())
url = get_real_url(entry)
{guid, url} = real_guid_and_url(entry)
Logger.debug("Creating item for #{url}")
@ -27,12 +29,14 @@ defmodule Frenzy.Task.CreateItem do
end
item_params = %{
guid: entry.guid,
guid: guid,
title: entry.title,
url: url,
date: date,
creator: "",
content: entry.content
creator: entry.creator,
content: entry.content,
# we assume text/html in the feed itself, other stages may alter this
content_type: "text/html"
}
feed = Repo.preload(feed, :pipeline)
@ -42,6 +46,13 @@ defmodule Frenzy.Task.CreateItem do
feed.pipeline.stages
|> Enum.reduce({:ok, item_params}, fn
stage, {:ok, item_params} ->
if Frenzy.sentry_enabled?() do
Sentry.Context.add_breadcrumb(%{
category: "pipeline",
message: stage["module_name"]
})
end
apply(String.to_existing_atom("Elixir." <> stage["module_name"]), :apply, [
stage["options"],
item_params
@ -57,38 +68,73 @@ defmodule Frenzy.Task.CreateItem do
{:ok, item_params}
end
case result do
{:err, error} ->
Logger.error(error)
changeset =
case result do
{:error, error} ->
Logger.error(error)
{:ok, item_params} ->
changeset = Ecto.build_assoc(feed, :items, item_params)
if Frenzy.sentry_enabled?() do
Sentry.capture_message(
"Error evaluating pipeline: #{inspect(error)}",
extra: %{
feed_id: feed.id,
pipeline_id: feed.pipeline.id,
pipeline_name: feed.pipeline.name
}
)
end
case Repo.insert(changeset) do
{:ok, item} ->
item
:error
{:error, changeset} ->
Logger.error("Error inserting item #{entry.guid}")
Logger.error(changeset)
end
{:ok, item_params} ->
item_params = Map.put(item_params, :feed_id, feed.id)
Item.changeset(%Item{}, item_params)
:tombstone ->
changeset =
Ecto.build_assoc(feed, :items, %{
:tombstone ->
Item.changeset(%Item{}, %{
guid: item_params.guid,
tombstone: true
})
end
case changeset do
nil ->
nil
changeset ->
case Repo.insert(changeset) do
{:ok, item} ->
item
{:error, changeset} ->
Logger.error("Error inserting tombstone for #{entry.guid}")
Logger.error(changeset)
with [feed_id: {_, list}] <- changeset.errors,
true <- {:constraint_name, "items_feed_guid_index"} in list do
Logger.warning("Did not insert duplicate item for #{item_params.guid}")
else
_ ->
Logger.error("Error inserting item #{item_params.guid}")
Logger.error(changeset.errors)
if Frenzy.sentry_enabled?() do
Sentry.capture_message("Error inserting item: #{inspect(changeset.errors)}",
extra: %{
item_guid: item_params.guid,
feed_id: feed.id,
errors: changeset.errors
}
)
end
end
end
end
:ok
end
def real_guid_and_url(entry) do
url = get_real_url(entry)
# fallback to url if guid isn't present
{entry.guid || url, url}
end
defp get_real_url(entry) do

View File

@ -1,7 +1,7 @@
defmodule Frenzy.Task.FetchFavicon do
require Logger
use Task
alias Frenzy.{HTTP, Repo, Feed}
alias Frenzy.{Network, Repo, Feed}
def start_link(feed) do
Task.start_link(__MODULE__, :run, [feed])
@ -13,42 +13,42 @@ defmodule Frenzy.Task.FetchFavicon do
site_url =
case feed.site_url do
url when is_binary(url) ->
url
URI.parse(url)
_ ->
%URI{URI.parse(feed.feed_url) | path: nil, query: nil, fragment: nil} |> URI.to_string()
%URI{URI.parse(feed.feed_url) | path: nil, query: nil, fragment: nil}
end
Logger.debug("Fetching favicon for #{site_url}")
if site_url.scheme in ["http", "https"] do
Logger.debug("Fetching favicon for #{site_url}")
favicon_url = fetch_favicon_url_from_webpage(site_url) || URI.merge(site_url, "/favicon.ico")
favicon_url =
fetch_favicon_url_from_webpage(site_url) || URI.merge(site_url, "/favicon.ico")
with %Feed{favicon_url: old_url} when old_url != favicon_url <- feed,
{:ok, favicon_data} <- fetch_favicon_data(favicon_url) do
changeset =
Feed.changeset(feed, %{
favicon: favicon_data,
favicon_url: to_string(favicon_url)
})
with true <- is_binary(favicon_url),
%Feed{favicon_url: old_url} when old_url != favicon_url <- feed,
{:ok, favicon_data} <- fetch_favicon_data(favicon_url) do
changeset =
Feed.changeset(feed, %{
favicon: favicon_data,
favicon_url: to_string(favicon_url)
})
{:ok, _feed} = Repo.update(changeset)
else
_ ->
:ok
{:ok, _feed} = Repo.update(changeset)
else
_ ->
:ok
end
end
end
@spec fetch_favicon_url_from_webpage(url :: String.t()) :: String.t()
defp fetch_favicon_url_from_webpage(url) when is_binary(url) do
case HTTP.get(url) do
{:ok, %HTTPoison.Response{body: body, status_code: code}} when code in 200..299 ->
case Network.http_get(url) do
{:ok, %Tesla.Env{body: body, status: code}} when code in 200..299 ->
extract_favicon_url(url, body)
{:ok, %HTTPoison.Response{status_code: code}} ->
Logger.debug("Unhandled HTTP code #{code} for '#{url}'")
nil
{:error, reason} ->
Logger.debug("Error fetching webpage for favicon: #{inspect(reason)}")
nil
@ -59,7 +59,7 @@ defmodule Frenzy.Task.FetchFavicon do
@spec extract_favicon_url(page_url :: String.t(), body :: term()) :: String.t()
defp extract_favicon_url(page_url, body) do
html_tree = Floki.parse(body)
{:ok, html_tree} = Floki.parse_document(body)
case Floki.find(html_tree, "link[rel=icon]") do
[] ->
@ -108,14 +108,10 @@ defmodule Frenzy.Task.FetchFavicon do
defp fetch_favicon_data(favicon_url) do
Logger.debug("Fetching favicon from: '#{favicon_url}'")
case HTTP.get(favicon_url) do
{:ok, %HTTPoison.Response{body: body, status_code: code}} when code in 200..299 ->
case Network.http_get(favicon_url) do
{:ok, %Tesla.Env{body: body, status: code}} when code in 200..299 ->
{:ok, "data:image/png;base64,#{Base.encode64(body)}"}
{:ok, %HTTPoison.Response{status_code: code}} ->
Logger.debug("Unhandled HTTP code #{code} for '#{favicon_url}'")
:error
{:error, reason} ->
Logger.debug("Error fetching favicon: #{inspect(reason)}")
:error

View File

@ -1,6 +1,6 @@
defmodule Frenzy.UpdateFeeds do
use GenServer
alias Frenzy.{HTTP, Repo, Feed, Item}
alias Frenzy.{Network, Repo, Feed, Item}
alias Frenzy.Task.{CreateItem, FetchFavicon}
import Ecto.Query
require Logger
@ -14,8 +14,7 @@ defmodule Frenzy.UpdateFeeds do
end
def init(state) do
update_feeds()
schedule_update()
Process.send_after(self(), :update_feeds, 5 * 1000)
{:ok, state}
end
@ -24,12 +23,20 @@ defmodule Frenzy.UpdateFeeds do
{:noreply, state}
end
def handle_info({:update_feed, feed_id, retry_count}, state) do
update_feed(Repo.get(Feed, feed_id), retry_count)
{:noreply, state}
end
def handle_info(:update_feeds, state) do
update_feeds()
schedule_update()
{:noreply, state}
end
# workaround for unhanled {:ssl_closed, {:sslsocket, {:gen_tcp, ...}}} message when Gemini module
def handle_info({:ssl_closed, _}, state), do: {:noreply, state}
defp schedule_update() do
# 30 minutes
Process.send_after(self(), :update_feeds, 30 * 60 * 1000)
@ -50,20 +57,37 @@ defmodule Frenzy.UpdateFeeds do
Logger.info("Updating #{count} feeds")
Enum.each(feeds, &update_feed/1)
do_update_feeds(feeds)
prune_old_items()
end
def force_update_feeds() do
feeds = Repo.all(Feed)
Logger.info("Force updating #{Enum.count(feeds)} feeds")
do_update_feeds(feeds)
end
defp do_update_feeds(feeds) do
Enum.each(feeds, fn feed ->
try do
update_feed(feed)
rescue
error ->
Logger.warn(
Logger.warning(
"Encountered error updating feed #{feed.id} #{feed.feed_url}: #{inspect(error)}"
)
if Frenzy.sentry_enabled?() do
Sentry.capture_exception(error,
stacktrace: __STACKTRACE__,
extra: %{feed_id: feed.id, feed_url: feed.feed_url}
)
end
end
end)
prune_old_items()
end
defp prune_old_items() do
@ -83,19 +107,32 @@ defmodule Frenzy.UpdateFeeds do
Logger.info("Converted #{count} read items to tombstones")
end
defp update_feed(feed) do
defp update_feed(feed, retry_count \\ 0) do
Logger.debug("Updating #{feed.feed_url}")
case HTTP.get(feed.feed_url) do
case URI.parse(feed.feed_url) do
%URI{scheme: "gemini"} = uri ->
update_feed_gemini(feed, uri, retry_count)
%URI{scheme: scheme} when scheme in ["http", "https"] ->
update_feed_http(feed, retry_count)
%URI{scheme: scheme} ->
Logger.warning("Unhandled scheme for feed: #{scheme}")
end
end
defp update_feed_http(feed, retry_count) do
case Network.http_get(feed.feed_url) do
{:ok,
%HTTPoison.Response{
status_code: 200,
%Tesla.Env{
status: 200,
body: body,
headers: headers
}} ->
{_, content_type} =
headers
|> Enum.find(fn {k, _v} -> k == "Content-Type" end)
|> Enum.find(fn {k, _v} -> String.downcase(k) == "content-type" end)
content_type =
content_type
@ -103,36 +140,77 @@ defmodule Frenzy.UpdateFeeds do
|> Enum.map(&String.trim/1)
|> Enum.find(fn s -> !String.contains?(s, "=") end)
case FeedParser.parse(body, content_type) do
{:ok, rss} ->
update_feed_from_rss(feed, rss)
do_update_feed(feed, content_type, body)
{:error, reason} ->
Logger.error("Unable to parse feed at '#{feed.feed_url}': #{inspect(reason)}")
{:ok, %Tesla.Env{status: status}} ->
Logger.error("Couldn't load feed #{feed.feed_url}: HTTP #{status}")
if Frenzy.sentry_enabled?() do
Sentry.capture_message("Got HTTP #{status} when loading feed '#{feed.feed_url}'",
extra: %{feed_id: feed.id}
)
end
{:ok, %HTTPoison.Response{status_code: 404}} ->
Logger.warn("RSS feed #{feed.feed_url} not found")
{:error, reason} ->
if retry_count < 5 do
Process.send_after(
self(),
{:update_feed, feed, retry_count + 1},
trunc(:math.pow(4, retry_count)) * 1000
)
else
Logger.error("Couldn't load feed #{feed.feed_url}: #{inspect(reason)}")
{:ok, %HTTPoison.Response{status_code: status_code, headers: headers}}
when status_code in [301, 302] ->
{"Location", new_url} =
Enum.find(headers, fn {name, _value} ->
name == "Location"
end)
if Frenzy.sentry_enabled?() do
Sentry.capture_message("Error loading HTTP feed: #{inspect(reason)}",
extra: %{feed_id: feed.id, feed_url: feed.feed_url}
)
end
end
end
end
Logger.debug("Got 301 redirect from #{feed.feed_url} to #{new_url}, updating feed URL")
changeset = Feed.changeset(feed, %{feed_url: new_url})
{:ok, feed} = Repo.update(changeset)
update_feed(feed)
defp update_feed_gemini(feed, feed_uri, retry_count) do
case Network.gemini_request(feed_uri) do
{:ok, %Gemini.Response{meta: content_type, body: body}} ->
do_update_feed(feed, content_type, body)
{:ok, %HTTPoison.Response{} = response} ->
Logger.error(
"Couldn't load RSS feed #{feed.feed_url}, got unexpected response: #{inspect(response)}"
)
{:error, reason} ->
if retry_count < 5 do
Process.send_after(
self(),
{:update_feed, feed, retry_count + 1},
trunc(:math.pow(4, retry_count)) * 1000
)
else
Logger.error("Couldn't load feed #{feed.feed_url}: #{inspect(reason)}")
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("Couldn't load RSS feed #{feed.feed_url}: #{inspect(reason)}")
if Frenzy.sentry_enabled?() do
Sentry.capture_message(
"Error loading Gemini feed: #{inspect(reason)}",
extra: %{feed_id: feed.id, feed_url: feed.feed_url}
)
end
end
end
end
defp do_update_feed(feed, content_type, data) do
case FeedParser.parse(data, content_type) do
{:ok, rss} ->
update_feed_from_rss(feed, rss)
{:error, :no_data} ->
:ok
{:error, reason} ->
Logger.error("Unable to parse feed at '#{feed.feed_url}': #{inspect(reason)}")
if Frenzy.sentry_enabled?() do
Sentry.capture_message("Unable to parse feed: #{inspect(reason)}",
extra: %{feed_id: feed.id, feed_url: feed.feed_url}
)
end
end
end
@ -150,11 +228,10 @@ defmodule Frenzy.UpdateFeeds do
FetchFavicon.run(feed)
end
feed = Repo.preload(feed, [:items])
Enum.each(rss.items, fn entry ->
# todo: use Repo.exists for this
if !Enum.any?(feed.items, fn item -> item.guid == entry.guid end) do
{guid, _} = CreateItem.real_guid_and_url(entry)
unless Item.exists?(feed.id, guid) do
CreateItem.start_link(feed, entry)
end
end)

View File

@ -8,10 +8,12 @@ defmodule Frenzy.User do
field :password_hash, :string
field :fever_password, :string, virtual: true
field :fever_auth_token, :string
field :oidc_subject, :string
has_many :approved_clients, Frenzy.ApprovedClient, on_delete: :delete_all
has_many :groups, Frenzy.Group, on_delete: :delete_all
has_many :feeds, through: [:groups, :feeds]
timestamps()
end
@ -24,6 +26,7 @@ defmodule Frenzy.User do
password_hash: String.t(),
fever_password: String.t() | nil,
fever_auth_token: String.t(),
oidc_subject: String.t() | nil,
approved_clients: [Frenzy.ApprovedClient.t()] | Ecto.Association.NotLoaded.t(),
groups: [Frenzy.Group.t()] | Ecto.Association.NotLoaded.t(),
inserted_at: NaiveDateTime.t(),
@ -60,6 +63,11 @@ defmodule Frenzy.User do
|> put_fever_token()
end
def set_oidc_subject_changeset(user, attrs) do
user
|> cast(attrs, [:oidc_subject])
end
defp put_password_hash(
%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
) do

View File

@ -15,7 +15,8 @@ defmodule FrenzyWeb.AccountController do
render(conn, "show.html", %{
user: user,
clients: clients
clients: clients,
can_link_oidc: Frenzy.oidc_enabled?() && user.oidc_subject in [nil, ""]
})
end
@ -147,4 +148,10 @@ defmodule FrenzyWeb.AccountController do
opml = Frenzy.OPML.Exporter.export(user.groups)
send_download(conn, {:binary, opml}, filename: "frenzy_export.opml")
end
def link_oidc(conn, _params) do
conn
|> put_session(:continue_path, Routes.account_path(conn, :show))
|> redirect(to: Routes.login_path(conn, :ueberauth_request, "oidc"))
end
end

View File

@ -1,19 +1,17 @@
defmodule FrenzyWeb.Fervor.ItemsController do
use FrenzyWeb, :controller
alias Frenzy.{Repo, Item}
alias Frenzy.{Repo, Item, Group, Feed}
import Ecto.Query
alias FrenzyWeb.Fervor.Paginator
plug :get_specific_item
def get_specific_item(%Plug.Conn{path_params: %{"id" => id}} = conn, _opts) do
user = conn.assigns[:user] |> Repo.preload(groups: [:feeds])
feeds = Enum.flat_map(user.groups, fn g -> g.feeds end)
def get_specific_item(%Plug.Conn{path_params: %{"id" => id}} = conn, _opts) when id != "sync" do
user = conn.assigns[:user] |> Repo.preload(:feeds)
item = Repo.get(Item, id)
if Enum.any?(feeds, fn f -> f.id == item.feed_id end) do
if Enum.any?(user.feeds, fn f -> f.id == item.feed_id end) do
assign(conn, :item, item)
else
conn
@ -58,14 +56,18 @@ defmodule FrenzyWeb.Fervor.ItemsController do
json(conn, Item.to_fervor(item))
end
def mark_item(conn, changes) do
defp mark_item(conn, changes) do
item = conn.assigns[:item] |> Repo.preload(:feed)
changeset = Item.changeset(item, changes)
{:ok, item} = Repo.update(changeset)
if changeset.valid? do
{:ok, item} = Repo.update(changeset)
json(conn, Item.to_fervor(item))
json(conn, Item.to_fervor(item))
else
json(conn, Item.to_fervor(item))
end
end
def read_specific_item(conn, _params) do
@ -76,7 +78,7 @@ defmodule FrenzyWeb.Fervor.ItemsController do
mark_item(conn, %{read: false})
end
def mark_multiple_items(conn, %{"ids" => ids}, changes) do
defp mark_multiple_items(conn, %{"ids" => ids}, changes) do
user = conn.assigns[:user] |> Repo.preload(groups: [:feeds])
feeds = Enum.flat_map(user.groups, fn g -> g.feeds end)
@ -92,14 +94,14 @@ defmodule FrenzyWeb.Fervor.ItemsController do
Repo.get(Item, id)
end)
|> Enum.filter(fn item ->
Enum.any?(feeds, fn f -> f.id == item.feed_id end)
Enum.any?(feeds, fn f -> f.id == item.feed_id end) && !item.tombstone
end)
|> Enum.map(fn item ->
item = Repo.preload(item, :feed)
changeset = Item.changeset(item, changes)
case Repo.update(changeset) do
{:ok, item} -> item.id
{:ok, item} -> to_string(item.id)
_ -> nil
end
end)
@ -108,7 +110,7 @@ defmodule FrenzyWeb.Fervor.ItemsController do
json(conn, read_ids)
end
def mark_multiple_items(conn, _params, _changes) do
defp mark_multiple_items(conn, _params, _changes) do
conn
|> put_status(400)
|> json(%{error: "No items provided."})
@ -121,4 +123,50 @@ defmodule FrenzyWeb.Fervor.ItemsController do
def unread_multiple(conn, params) do
mark_multiple_items(conn, params, %{read: false})
end
def sync(conn, params) do
sync_timestamp = Timex.now()
feed_ids =
Group
|> where([g], g.user_id == ^conn.assigns.user.id)
|> join(:inner, [g], f in Feed, on: f.group_id == g.id)
|> select([g, f], f.id)
|> Repo.all()
last_sync =
with s when is_binary(s) <- Map.get(params, "last_sync"),
{:ok, datetime} <- Timex.parse(s, "{ISO:Extended:Z}") do
datetime
else
_ ->
nil
end
{deleted_ids, upserted} =
case last_sync do
nil ->
items =
Item
|> where([i], not i.tombstone and i.feed_id in ^feed_ids)
|> order_by([i], desc: i.inserted_at)
|> limit(1000)
|> Repo.all()
{[], items}
_ ->
all_items =
Repo.all(from i in Item, where: i.feed_id in ^feed_ids and i.updated_at >= ^last_sync)
{tombstones, rest} = Enum.split_with(all_items, & &1.tombstone)
{Enum.map(tombstones, & &1.id), rest}
end
json(conn, %{
sync_timestamp: Timex.format!(sync_timestamp, "{ISO:Extended:Z}"),
delete: Enum.map(deleted_ids, &to_string/1),
upsert: Enum.map(upserted, &Item.to_fervor/1)
})
end
end

View File

@ -110,7 +110,7 @@ defmodule FrenzyWeb.Fervor.OauthController do
)
uri = %URI{parsed | query: query}
redirect(conn, to: uri)
redirect(conn, external: URI.to_string(uri))
end
end
@ -174,7 +174,8 @@ defmodule FrenzyWeb.Fervor.OauthController do
json(conn, %{
access_token: access_token,
token_type: "bearer"
token_type: "bearer",
owner: to_string(approved_client.user_id)
})
end
end

View File

@ -196,7 +196,12 @@ defmodule FrenzyWeb.FeverController do
|> Enum.map(fn f -> f.id end)
unread =
Repo.all(from i in Item, where: i.feed_id in ^feed_ids, where: [read: false])
Repo.all(
from i in Item,
where: i.feed_id in ^feed_ids and not i.read,
limit: 10000,
order_by: [desc: :id]
)
|> Enum.map(fn item -> item.id end)
|> Enum.join(",")
@ -222,19 +227,20 @@ defmodule FrenzyWeb.FeverController do
items =
cond do
Map.has_key?(params, "with_ids") ->
params["with_ids"]
|> String.split(",")
|> Enum.map(fn id ->
{id, _} = id |> String.trim() |> Integer.parse()
item = Repo.get(Item, id)
item_ids =
params["with_ids"]
|> String.split(",")
|> Enum.map(fn str ->
{id, _} = str |> String.trim() |> Integer.parse()
id
end)
if not is_nil(item) and item.feed_id in feed_ids do
item
else
nil
end
end)
|> Enum.reject(&is_nil/1)
Repo.all(
from i in Item,
where: i.id in ^item_ids,
where: i.feed_id in ^feed_ids,
where: not i.tombstone
)
Map.has_key?(params, "since_id") ->
since = Repo.get(Item, params["since_id"])

View File

@ -10,9 +10,7 @@ defmodule FrenzyWeb.ItemController do
item = Repo.get(Item, id)
feeds = Enum.flat_map(user.groups, fn g -> g.feeds end)
if Enum.any?(feeds, fn f -> f.id == item.feed_id end) do
if Enum.any?(user.feeds, fn f -> f.id == item.feed_id end) do
conn
|> assign(:item, item)
else
@ -34,7 +32,7 @@ defmodule FrenzyWeb.ItemController do
})
end
def read(conn, _params) do
def read(conn, params) do
item = conn.assigns[:item] |> Repo.preload(:feed)
changeset =
@ -44,10 +42,11 @@ defmodule FrenzyWeb.ItemController do
})
{:ok, item} = Repo.update(changeset)
redirect(conn, to: Routes.item_path(Endpoint, :show, item.id))
path = Map.get(params, "redirect") || Routes.item_path(Endpoint, :show, item.id)
redirect(conn, to: path)
end
def unread(conn, _params) do
def unread(conn, params) do
item = conn.assigns[:item] |> Repo.preload(:feed)
changeset =
@ -56,7 +55,8 @@ defmodule FrenzyWeb.ItemController do
read_date: nil
})
Repo.update(changeset)
redirect(conn, to: Routes.item_path(Endpoint, :show, item.id))
{:ok, item} = Repo.update(changeset)
path = Map.get(params, "redirect") || Routes.item_path(Endpoint, :show, item.id)
redirect(conn, to: path)
end
end

View File

@ -3,27 +3,29 @@ defmodule FrenzyWeb.LoginController do
alias Frenzy.{Repo, User}
alias FrenzyWeb.Endpoint
if Frenzy.oidc_enabled?() do
plug Ueberauth
end
def login(conn, params) do
render(conn, "login.html", %{
continue: Map.get(params, "continue")
conn
|> put_session(:continue_path, Map.get(params, "continue"))
|> render("login.html", %{
oidc_enabled?: Frenzy.oidc_enabled?()
})
end
def login_post(conn, %{"username" => username, "password" => password} = params) do
def login_post(conn, %{"username" => username, "password" => password}) do
user = Repo.get_by(User, username: username)
case Bcrypt.check_pass(user, password) do
{:ok, user} ->
user_token = Phoenix.Token.sign(Endpoint, "user token", user.id)
conn = put_session(conn, :user_token, user_token)
redirect_uri = Map.get(params, "continue") || Routes.group_path(Endpoint, :index)
redirect(conn, to: redirect_uri)
put_user_and_redirect(conn, user)
{:error, _reason} ->
conn
|> put_flash(:error, "Invalid username or password.")
|> redirect(to: Routes.login_path(Endpoint, :login))
|> redirect(to: Routes.login_path(Endpoint, :login, continue: continue_path(conn)))
end
end
@ -33,4 +35,52 @@ defmodule FrenzyWeb.LoginController do
|> clear_session()
|> redirect(to: "/")
end
def ueberauth_callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do
conn
|> put_flash(:error, "Failed to authenticate.")
|> redirect(to: Routes.login_path(Endpoint, :login, continue: continue_path(conn)))
end
def ueberauth_callback(
%{assigns: %{ueberauth_auth: %{credentials: %{other: %{user_info: %{"sub" => subject}}}}}} =
conn,
_params
) do
case Repo.get_by(User, oidc_subject: subject) do
nil ->
conn = FrenzyWeb.Plug.Authenticate.call(conn, nil)
case conn.assigns.user do
%User{} = user ->
changeset = User.set_oidc_subject_changeset(user, %{oidc_subject: subject})
{:ok, _user} = Repo.update(changeset)
conn
|> put_flash(:info, "Successfully linked OIDC.")
|> redirect(to: continue_path(conn))
_ ->
# TODO: register new user for subject
conn
|> put_flash(:error, "No matching OIDC subject.")
|> redirect(to: Routes.login_path(Endpoint, :login, continue: continue_path(conn)))
end
user ->
put_user_and_redirect(conn, user)
end
end
defp continue_path(conn) do
get_session(conn, :continue_path) || Routes.group_path(Endpoint, :index)
end
defp put_user_and_redirect(conn, user) do
user_token = Phoenix.Token.sign(Endpoint, "user token", user.id)
conn
|> put_session(:user_token, user_token)
|> redirect(to: continue_path(conn))
end
end

View File

@ -1,4 +1,8 @@
defmodule FrenzyWeb.Endpoint do
if Frenzy.sentry_enabled?() do
use Sentry.PlugCapture
end
use Phoenix.Endpoint, otp_app: :frenzy
@session_options [
@ -21,7 +25,7 @@ defmodule FrenzyWeb.Endpoint do
at: "/",
from: :frenzy,
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
only: ~w(assets fonts images favicon.ico robots.txt)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
@ -39,6 +43,10 @@ defmodule FrenzyWeb.Endpoint do
pass: ["*/*"],
json_decoder: Phoenix.json_library()
if Frenzy.sentry_enabled?() do
plug Sentry.PlugContext
end
plug Plug.MethodOverride
plug Plug.Head

View File

@ -1,16 +1,16 @@
<div id="<%= @id %>">
<%= if Mix.env == :dev do %>
<div id={@id}>
<%= if Application.fetch_env!(:frenzy, :env) == :dev do %>
<pre><%= Jason.encode!(@opts, pretty: true) %></pre>
<% end %>
<%= f = form_for @opts, "#", [as: :opts, phx_change: :update_stage, phx_target: @myself] %>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="<%= @id %>-stage">Module</label>
<%= form_for @opts, "#", [as: :opts, phx_change: :update_stage, phx_target: @myself], fn f -> %>
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for={"#{@id}-stage"}>Module</label>
<div class="col-sm-10">
<%= select f, :stage, @stages, prompt: "Select a stage...", id: "#{@id}-stage", class: "custom-select" %>
<%= select f, :stage, @stages, prompt: "Select a stage...", id: "#{@id}-stage", class: "form-select" %>
</div>
</div>
</form>
<% end %>
<div class="card mb-2">
<div class="card-header">
@ -19,7 +19,7 @@
<div class="card-body">
<% component = component_module(@opts["stage"]) %>
<%= unless is_nil(component) do %>
<%= live_component(@socket, component, index: @index, id: "#{@id}-conditional", stage: @stage, keypath: @keypath ++ ["opts"]) %>
<%= live_component(component, index: @index, id: "#{@id}-conditional", stage: @stage, keypath: @keypath ++ ["opts"]) %>
<% end %>
</div>
</div>
@ -31,8 +31,8 @@
<div class="col">
<h4 class="m-0">Condition: Filter</h4>
</div>
<div class="col text-right">
<button phx-click="convert_to_rule" phx-target="#<%= @id %>" class="btn btn-primary btn-sm">Convert to Rule</button>
<div class="col text-end">
<button phx-click="convert_to_rule" phx-target={@id} class="btn btn-primary btn-sm">Convert to Rule</button>
</div>
</div>
</div>
@ -40,12 +40,12 @@
<%= if @confirm_convert_to_rule do %>
<div class="alert alert-danger mb-2">
<p>This will modify the conditional stage to only run when the first rule is met. Are you sure you want to proceed?</p>
<button class="btn btn-danger btn-sm" phx-click="confirm_convert_to_rule" phx-target="#<%= @id %>">Convert to Rule</button>
<button class="btn btn-secondary btn-sm" phx-click="cancel_convert_to_rule" phx-target="#<%= @id %>">Cancel</button>
<button class="btn btn-danger btn-sm" phx-click="confirm_convert_to_rule" phx-target={@id}>Convert to Rule</button>
<button class="btn btn-secondary btn-sm" phx-click="cancel_convert_to_rule" phx-target={@id}>Cancel</button>
</div>
<% end %>
<%= live_component @socket, FrenzyWeb.FilterLive, id: "##{@id}-filter", parent_id: @id, filter: @opts["condition"] %>
<%= live_component FrenzyWeb.FilterLive, id: "##{@id}-filter", parent_id: @id, filter: @opts["condition"] %>
</div>
</div>
<% else %>
@ -55,13 +55,13 @@
<div class="col">
<h4 class="m-0">Condition: Rule</h4>
</div>
<div class="col text-right">
<button phx-click="convert_to_filter" phx-target="#<%= @id %>" class="btn btn-primary btn-sm">Convert to Filter</button>
<div class="col text-end">
<button phx-click="convert_to_filter" phx-target={@id} class="btn btn-primary btn-sm">Convert to Filter</button>
</div>
</div>
</div>
<div class="card-body">
<%= live_component @socket, FrenzyWeb.FilterRuleLive, id: "##{@id}-rule", parent_id: @id, rule: @opts["condition"], index: :no_index %>
<%= live_component FrenzyWeb.FilterRuleLive, id: "##{@id}-rule", parent_id: @id, rule: @opts["condition"], index: :no_index %>
</div>
</div>
<% end %>

View File

@ -25,7 +25,7 @@ defmodule FrenzyWeb.ConfigureStage.FilterStageLive do
def handle_event("add_rule", _params, socket) do
new_rules =
socket.assigns.opts["rules"] ++
[%{"mode" => "text", "param" => "", "property" => "title", "weight" => 1}]
[%{"mode" => "contains_string", "param" => "", "property" => "title", "weight" => 1}]
new_opts = Map.put(socket.assigns.opts, "rules", new_rules)
new_stage = Frenzy.Keypath.set(socket.assigns.stage, socket.assigns.keypath, new_opts)

View File

@ -0,0 +1,6 @@
<div id={@id}>
<%= if Application.fetch_env!(:frenzy, :env) == :dev do %>
<pre><%= Jason.encode!(@opts, pretty: true) %></pre>
<% end %>
<%= live_component FrenzyWeb.FilterLive, id: "#{@id}-filter", parent_id: @id, filter: @opts %>
</div>

View File

@ -1,6 +0,0 @@
<div id="<%= @id %>">
<%= if Mix.env == :dev do %>
<pre><%= Jason.encode!(@opts, pretty: true) %></pre>
<% end %>
<%= live_component @socket, FrenzyWeb.FilterLive, id: "#{@id}-filter", parent_id: @id, filter: @opts %>
</div>

View File

@ -3,12 +3,18 @@ defmodule FrenzyWeb.ConfigureStage.ScrapeStageLive do
@extractors [
{"Builtin", "builtin"},
{"512 Pixels", Frenzy.Pipeline.Extractor.FiveTwelvePixels},
{"Ars Technica", Frenzy.Pipeline.Extractor.ArsTechnica},
{"beckyhansmeyer.com", Frenzy.Pipeline.Extractor.BeckyHansmeyer},
{"birchtree.me", Frenzy.Pipeline.Extractor.Birchtree},
{"daringfireball.net", Frenzy.Pipeline.Extractor.DaringFireball},
{"Election Law Blog", Frenzy.Pipeline.Extractor.ElectionLawBlog},
{"ericasadun.com", Frenzy.Pipeline.Extractor.EricaSadun},
{"finertech.com", Frenzy.Pipeline.Extractor.FinerTech},
{"macstories.net", Frenzy.Pipeline.Extractor.MacStories},
{"om.co", Frenzy.Pipeline.Extractor.OmMalik},
{"slate.com", Frenzy.Pipeline.Extractor.Slate},
{"The Verge", Frenzy.Pipeline.Extractor.TheVerge},
{"whatever.scalzi.com", Frenzy.Pipeline.Extractor.WhateverScalzi}
]
|> Enum.map(fn {pretty_name, module} ->
@ -21,11 +27,6 @@ defmodule FrenzyWeb.ConfigureStage.ScrapeStageLive do
}
end)
@schema %{
"convert_to_data_uris" => :boolean,
"extractor" => :string
}
@impl true
def mount(socket) do
{:ok, assign(socket, extractors: @extractors)}

View File

@ -0,0 +1,17 @@
<div id={@id}>
<%= if Application.fetch_env!(:frenzy, :env) == :dev do %>
<pre><%= Jason.encode!(@opts, pretty: true) %></pre>
<% end %>
<%= form_for @opts, "#", [as: :opts, phx_change: :update_stage, phx_target: @myself], fn f -> %>
<div class="form-check mb-2">
<%= checkbox f, :convert_to_data_uris, id: "#{@id}-convert_to_data_uris", class: "form-check-input" %>
<label class="form-check-label" for={"#{@id}-convert_to_data_uris"}>Convert Images to Embedded Data URIs</label>
</div>
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for={"#{@id}-extractor"}>Extractor</label>
<div class="col-sm-10">
<%= select f, :extractor, @extractors, id: "#{@id}-extractor", class: "form-select" %>
</div>
</div>
<% end %>
</div>

View File

@ -1,17 +0,0 @@
<div id="<%= @id %>">
<%= if Mix.env == :dev do %>
<pre><%= Jason.encode!(@opts, pretty: true) %></pre>
<% end %>
<%= f = form_for @opts, "#", [as: :opts, phx_change: :update_stage, phx_target: @myself] %>
<div class="form-group form-check">
<%= checkbox f, :convert_to_data_uris, id: "#{@id}-convert_to_data_uris", class: "form-check-input" %>
<label class="form-check-label" for="<%= @id %>-convert_to_data_uris">Convert Images to Embedded Data URIs</label>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="<%= @id %>-extractor">Extractor</label>
<div class="col-sm-10">
<%= select f, :extractor, @extractors, id: "#{@id}-extractor", class: "custom-select" %>
</div>
</div>
</form>
</div>

View File

@ -3,10 +3,16 @@ defmodule FrenzyWeb.EditPipelineLive do
use Phoenix.HTML
alias Frenzy.{Repo, Pipeline}
def title(%{pipeline: %Pipeline{name: name}}) do
"Edit #{name}"
end
@stages [
{"Filter Stage", "Frenzy.Pipeline.FilterStage"},
{"Scrape Stage", "Frenzy.Pipeline.ScrapeStage"},
{"Conditional Stage", "Frenzy.Pipeline.ConditionalStage"}
{"Conditional Stage", "Frenzy.Pipeline.ConditionalStage"},
{"Gemini Scrape Stage", "Frenzy.Pipeline.GeminiScrapeStage"},
{"Render Gemini Stage", "Frenzy.Pipeline.RenderGeminiStage"}
]
def stages, do: @stages
@ -100,13 +106,13 @@ defmodule FrenzyWeb.EditPipelineLive do
end
end
def component_for(socket, %{"module_name" => module} = stage, index) do
def component_for(%{"module_name" => module} = stage, index) do
case component_module(module) do
nil ->
nil
component ->
live_component(socket, component,
live_component(component,
index: index,
id: "stage-#{index}",
stage: stage,

View File

@ -1,6 +1,6 @@
<h1>Edit <%= @pipeline.name %></h1>
<a href="<%= Routes.pipeline_path(FrenzyWeb.Endpoint, :edit, @pipeline.id, json: "") %>" class="btn btn-primary">Edit as JSON</a>
<a href={Routes.pipeline_path(FrenzyWeb.Endpoint, :edit, @pipeline.id, json: "")} class="btn btn-primary">Edit as JSON</a>
<%= for {stage, index} <- Enum.with_index(@pipeline.stages) do %>
<div class="card mt-4">
@ -9,27 +9,27 @@
<div class="col">
<h4 class="m-0"><%= stage["module_name"] %></h4>
</div>
<div class="col text-right">
<div class="col text-end">
<%= content_tag :button, "Move Up", [phx_click: :move_up, phx_value_index: index, disabled: index == 0, class: "btn btn-secondary btn-sm"] %>
<%= content_tag :button, "Move Down", [phx_click: :move_down, phx_value_index: index, disabled: index == length(@pipeline.stages) - 1, class: "btn btn-secondary btn-sm"] %>
<button phx-click="delete_stage" phx-value-index="<%= index %>" class="btn btn-danger btn-sm">Delete</button>
<button phx-click="delete_stage" phx-value-index={index} class="btn btn-danger btn-sm">Delete</button>
</div>
</div>
</div>
<div class="card-body">
<%= component_for(@socket, stage, index) %>
<%= component_for(stage, index) %>
</div>
</div>
<% end %>
<%= f = form_for :stage, "#", [class: "mt-4 mb-4", phx_submit: :add_stage] %>
<div class="form-group row">
<%= form_for :stage, "#", [class: "mt-4 mb-4", phx_submit: :add_stage], fn f -> %>
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for="module_name">Module</label>
<div class="col-sm-10">
<%= select f, :module_name, @stages, class: "custom-select" %>
<%= select f, :module_name, @stages, class: "form-select" %>
</div>
</div>
<%= submit "Add Stage", class: "btn btn-primary" %>
</form>
<% end %>

View File

@ -0,0 +1,37 @@
<div id={@id}>
<%= form_for @filter, "#", [phx_change: :update_filter, phx_target: "##{@parent_id}"], fn f -> %>
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for={"#{@id}-mode"}>Mode</label>
<div class="col-sm-10">
<%= select f, :mode, @modes, id: "#{@id}-mode", class: "form-select" %>
</div>
</div>
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for={"#{@id}-score"}>Score</label>
<div class="col-sm-10">
<%= number_input f, :score, id: "#{@id}-score", class: "form-control" %>
</div>
</div>
<% end %>
<%= for {rule, index} <- Enum.with_index(@filter["rules"]) do %>
<div class="card mt-2">
<div class="card-header container-fluid">
<div class="row">
<div class="col">
<h4 class="m-0">Rule <%= index %></h4>
</div>
<div class="col text-end">
<button phx-click="delete_rule" phx-value-index={index} phx-target={"##{@id}"} class="btn btn-danger btn-sm">Delete</button>
</div>
</div>
</div>
<div class="card-body">
<%= live_component FrenzyWeb.FilterRuleLive, id: "#{@id}-rule-#{index}", parent_id: @parent_id, rule: rule, index: index %>
</div>
</div>
<% end %>
<%= form_for :rule, "#", [class: "mt-2", phx_submit: :add_rule, phx_target: "##{@parent_id}"], fn _f -> %>
<%= submit "Add Rule", class: "btn btn-primary" %>
<% end %>
</div>

View File

@ -1,37 +0,0 @@
<div id="<%= @id %>">
<%= f = form_for @filter, "#", [phx_change: :update_filter, phx_target: "##{@parent_id}"] %>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="<%= @id %>-mode">Mode</label>
<div class="col-sm-10">
<%= select f, :mode, @modes, id: "#{@id}-mode", class: "custom-select" %>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="<%= @id %>-score">Score</label>
<div class="col-sm-10">
<%= number_input f, :score, id: "#{@id}-score", class: "form-control" %>
</div>
</div>
</form>
<%= for {rule, index} <- Enum.with_index(@filter["rules"]) do %>
<div class="card mt-2">
<div class="card-header container-fluid">
<div class="row">
<div class="col">
<h4 class="m-0">Rule <%= index %></h4>
</div>
<div class="col text-right">
<button phx-click="delete_rule" phx-value-index="<%= index %>" phx-target="#<%= @id %>" class="btn btn-danger btn-sm">Delete</button>
</div>
</div>
</div>
<div class="card-body">
<%= live_component @socket, FrenzyWeb.FilterRuleLive, id: "#{@id}-rule-#{index}", parent_id: @parent_id, rule: rule, index: index %>
</div>
</div>
<% end %>
<%= f = form_for :rule, "#", class: "mt-2", phx_submit: :add_rule, phx_target: "##{@parent_id}" %>
<%= submit "Add Rule", class: "btn btn-primary" %>
</form>
</div>

View File

@ -3,13 +3,15 @@ defmodule FrenzyWeb.FilterRuleLive do
@modes [
{"Contains Substring", "contains_string"},
{"Contains Substring (case sensitive)", "contains_string_case_sensitive"},
{"Matches Regex", "matches_regex"}
]
@properties [
{"Title", "title"},
{"URL", "url"},
{"Author", "author"}
{"Author", "author"},
{"Content", "content"}
]
@impl true

View File

@ -0,0 +1,22 @@
<div id={@id}>
<%= form_for @rule, "#", [phx_change: :update_rule, phx_target: "##{@parent_id}"], fn f -> %>
<%= hidden_input f, :index, value: @index %>
<div class="row">
<div class="col-3">
<%= select f, :property, @properties, id: "#{@id}-property", class: "form-select" %>
</div>
<div class="col-3">
<%= select f, :mode, @modes, id: "#{@id}-mode", class: "form-select" %>
</div>
<div class="col">
<%= text_input f, :param, id: "#{@id}-param", placeholder: if(@rule["mode"] == "contains_string", do: "substring", else: "regex"), class: "form-control text-monospace" %>
</div>
</div>
<div class="row mb-0 mt-4">
<label class="col-sm-2 col-form-label" for={"#{@id}-weight"}>Rule Weight</label>
<div class="col-sm-10">
<%= number_input f, :weight, id: "#{@id}-weight", class: "form-control" %>
</div>
</div>
<% end %>
</div>

View File

@ -1,29 +0,0 @@
<div id="<%= @id %>">
<%= f = form_for @rule, "#", [phx_change: :update_rule, phx_target: "##{@parent_id}"] %>
<%= hidden_input f, :index, value: @index %>
<div class="form-group row mb-2">
<label class="col-sm-2 col-form-label" for="<%= @id %>-property">Item Property</label>
<div class="col-sm-10">
<%= select f, :property, @properties, id: "#{@id}-property", class: "custom-select" %>
</div>
</div>
<div class="form-group row mb-2">
<label class="col-sm-2 col-form-label" for="<%= @id %>-mode">Mode</label>
<div class="col-sm-10">
<%= select f, :mode, @modes, id: "#{@id}-mode", class: "custom-select" %>
</div>
</div>
<div class="form-group row mb-2">
<label class="col-sm-2 col-form-label" for="<%= @id %>-param">Value</label>
<div class="col-sm-10">
<%= text_input f, :param, id: "#{@id}-param", placeholder: if(@rule["mode"] == "contains_string", do: "substring", else: "regex"), class: "form-control text-monospace" %>
</div>
</div>
<div class="form-group row mb-0">
<label class="col-sm-2 col-form-label" for="<%= @id %>-weight">Rule Weight</label>
<div class="col-sm-10">
<%= number_input f, :weight, id: "#{@id}-weight", class: "form-control" %>
</div>
</div>
</form>
</div>

View File

@ -25,7 +25,7 @@ defmodule FrenzyWeb.Plug.Authenticate do
|> halt()
user ->
user = Repo.preload(user, groups: [:feeds])
user = Repo.preload(user, [:groups, :feeds])
assign(conn, :user, user)
end
end

View File

@ -34,6 +34,15 @@ defmodule FrenzyWeb.Router do
post "/oauth/authorize", Fervor.OauthController, :authorize_post
end
scope "/auth", FrenzyWeb do
pipe_through :browser
if Frenzy.oidc_enabled?() do
get "/:unused", LoginController, :ueberauth_request
get "/:unused/callback", LoginController, :ueberauth_callback
end
end
scope "/", FrenzyWeb do
pipe_through :browser
pipe_through :browser_authenticate
@ -47,6 +56,10 @@ defmodule FrenzyWeb.Router do
post "/account/import", AccountController, :import
post "/account/export", AccountController, :export
if Frenzy.oidc_enabled?() do
get "/account/link_oidc", AccountController, :link_oidc
end
get "/", GroupController, :index
resources "/groups", GroupController
get "/groups/:id/read", GroupController, :read
@ -99,6 +112,7 @@ defmodule FrenzyWeb.Router do
post "/api/v1/feeds/:id/delete", FeedsController, :delete
get "/api/v1/items", ItemsController, :items_list
get "/api/v1/items/sync", ItemsController, :sync
get "/api/v1/items/:id", ItemsController, :specific_item
post "/api/v1/items/:id/read", ItemsController, :read_specific_item
post "/api/v1/items/:id/unread", ItemsController, :unread_specific_item

View File

@ -1,15 +1,15 @@
<h2>Change Fever Password</h2>
<%= form_tag Routes.account_path(@conn, :do_change_fever_password), method: :post do %>
<div class="form-group row">
<div class="row mb-2">
<label for="new_password" class="col-sm-2 col-form-label">New Fever Password</label>
<div class="col-sm-10">
<input type="password" name="new_password" id="new_password" minlength="8" class="form-control">
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<div class="col-sm-10">
<%= submit "Change Fever Password", class: "btn btn-primary" %>
</div>
</div>
<% end %>
<% end %>

View File

@ -1,27 +1,27 @@
<h2>Change Password</h2>
<%= form_tag Routes.account_path(@conn, :do_change_password), method: :post do %>
<div class="form-group row">
<div class="row mb-2">
<label for="old_password" class="col-sm-2 col-form-label">Old Password</label>
<div class="col-sm-10">
<input type="password" name="old_password" id="old_password" minlength="8" class="form-control">
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<label for="new_password" class="col-sm-2 col-form-label">New Password</label>
<div class="col-sm-10">
<input type="password" name="new_password" id="new_password" minlength="8" class="form-control">
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<label for="confirm_new_password" class="col-sm-2 col-form-label">Confirm New Password</label>
<div class="col-sm-10">
<input type="password" name="confirm_new_password" id="confirm_new_password" minlength="8" class="form-control">
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<div class="col-sm-10">
<%= submit "Change Password", class: "btn btn-primary" %>
</div>
</div>
<% end %>
<% end %>

View File

@ -20,43 +20,47 @@
</section>
<section class="card mt-4">
<h4 class="card-header">Security</h4>
<h4 class="card-header">Security</h4>
<ul class="list-group list-group-flush">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<a href="<%= Routes.account_path(@conn, :change_password) %>" class="btn btn-secondary">Change Password</a>
<a href="<%= Routes.account_path(@conn, :change_fever_password) %>" class="btn btn-secondary">Change Fever Password</a>
</li>
<%= if @can_link_oidc do %>
<li class="list-group-item">
<a href="<%= Routes.account_path(@conn, :change_password) %>" class="btn btn-secondary">Change Password</a>
<a href="<%= Routes.account_path(@conn, :change_fever_password) %>" class="btn btn-secondary">Change Fever Password</a>
<a href="<%= Routes.account_path(@conn, :link_oidc) %>" class="btn btn-secondary">Link OIDC</a>
</li>
<li class="list-group-item">
<% end %>
<li class="list-group-item">
<h5 class="card-title">Approved Clients</h5>
<table class="table table-striped">
<thead>
<tr>
<th>Client</th>
<th>Revoke Access</th>
</tr>
</thead>
<tbody>
<%= for {approved, fervor} <- @clients do %>
<tr>
<td>
<%= if fervor.website do %>
<a href="<%= fervor.website %>"><%= fervor.client_name %></a>
<% else %>
<%= fervor.client_name %>
<% end %>
</td>
<td>
<%= form_tag Routes.account_path(@conn, :remove_client), method: :post do %>
<input type="hidden" name="client_id" value="<%= approved.client_id %>">
<%= submit "Revoke", class: "btn btn-danger" %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table> </li>
</ul>
<table class="table table-striped">
<thead>
<tr>
<th>Client</th>
<th>Revoke Access</th>
</tr>
</thead>
<tbody>
<%= for {approved, fervor} <- @clients do %>
<tr>
<td>
<%= if fervor.website do %>
<a href="<%= fervor.website %>"><%= fervor.client_name %></a>
<% else %>
<%= fervor.client_name %>
<% end %>
</td>
<td>
<%= form_tag Routes.account_path(@conn, :remove_client), method: :post do %>
<input type="hidden" name="client_id" value="<%= approved.client_id %>">
<%= submit "Revoke", class: "btn btn-danger" %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</li>
</ul>
</section>

View File

@ -1,32 +1,32 @@
<h1>Edit Feed</h1>
<%= form_for @changeset, Routes.feed_path(@conn, :update, @feed.id), fn f -> %>
<div class="form-group row">
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for="feed_url">Feed URL</label>
<div class="col-sm-10">
<%= text_input f, :feed_url, class: "form-control" %>
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for="refresh_frequency">Refresh Frequency</label>
<div class="col-sm-10">
<%= select f, :refresh_frequency, @refresh_frequencies, class: "custom-select" %>
<%= select f, :refresh_frequency, @refresh_frequencies, class: "form-select" %>
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for="pipeline_id">Pipeline ID</label>
<div class="col-sm-10">
<%= select f, :pipeline_id, @pipelines, class: "custom-select" %>
<%= select f, :pipeline_id, @pipelines, class: "form-select" %>
</div>
</div>
<%= if @feed.pipeline_id do %>
<div class="form-group row">
<div class="row mb-2">
<div class="col-sm-10">
<a href="<%= Routes.pipeline_path(@conn, :show, @feed.pipeline_id) %>" class="col-sm-2">View Pipeline</a>
</div>
</div>
<% end %>
<div class="form-group row">
<div class="row mb-2">
<div class="col-sm-10">
<%= submit "Update Feed", class: "btn btn-primary" %>
</div>

View File

@ -25,19 +25,32 @@
</p>
<% end %>
<table class="table table-striped">
<table class="table table-striped item-table">
<tbody>
<%= for item <- @items do %>
<tr <%= if item.read do %>class="item-read"<% end %>>
<td>
<a href="<%= Routes.item_path(@conn, :show, item.id) %>"><%= item.title || "(Untitled)" %></a>
</td>
<td>
<td class="date">
<%= if item.date do %>
<% {:ok, date} = Timex.format(item.date, "{YYYY}-{0M}-{0D} {0h12}:{m} {AM}") %>
<%= date %>
<% end %>
</td>
<td class="py-0 align-middle">
<%= if item.read do %>
<%= form_tag Routes.item_path(@conn, :unread, item.id), method: :post do %>
<input type="hidden" name="redirect" value="<%= current_path(@conn) %>">
<%= submit "Unread", class: "btn btn-sm btn-secondary" %>
<% end %>
<% else %>
<%= form_tag Routes.item_path(@conn, :read, item.id), method: :post do %>
<input type="hidden" name="redirect" value="<%= current_path(@conn) %>">
<%= submit "Read", class: "btn btn-sm btn-secondary" %>
<% end %>
<% end %>
</td>
</tr>
<% end %>
</tbody>

View File

@ -5,8 +5,8 @@
<%= if @state do %>
<input type="hidden" name="state" value="<%= @state %>">
<% end %>
<div class="form-group">
<div>
<%= submit "Grant access", class: "btn btn-primary" %>
<p>To reject the request, close this page.</p>
</div>
<% end %>
<% end %>

View File

@ -1,13 +1,15 @@
<h1>Edit Group</h1>
<%= form_for @changeset, Routes.group_path(@conn, :update, @group.id), fn f -> %>
<div class="form-group row">
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for="title">Title</label>
<div class="col-sm-10">
<%= text_input f, :title, class: "form-control" %>
</div>
</div>
<div class="form-group row">
<%= submit "Update Group", class: "btn btn-primary" %>
<div class="mb-2">
<div class="col-sm-10">
<%= submit "Update Group", class: "btn btn-primary" %>
</div>
</div>
<% end %>

View File

@ -1,13 +1,13 @@
<h1>New Group</h1>
<%= form_for @changeset, Routes.group_path(@conn, :create), fn form -> %>
<div class="form-group row">
<div class="row mb-2">
<label for="title" class="col-sm-2 col-form-label">Title</label>
<div class="col-sm-10">
<%= text_input form, :title, placeholder: "My New Group", class: "form-control" %>
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<div class="col-sm-10">
<%= submit "Create Group", class: "btn btn-primary" %>
</div>

View File

@ -6,7 +6,7 @@
</p>
<% end %>
<table class="table table-striped">
<table class="table table-striped item-table">
<tbody>
<%= for item <- @items do %>
<tr <%= if item.read do %>class="item-read"<% end %>>
@ -15,20 +15,33 @@
<%= item.title || "(Untitled)" %>
</a>
</td>
<td>
<a href="<%= Routes.feed_path(@conn, :show, item.feed.id) %>">
<td class="align-middle">
<a href="<%= Routes.feed_path(@conn, :show, item.feed.id) %>" style="white-space: nowrap;">
<%= if item.feed.favicon do %>
<img src="<%= item.feed.favicon %>" alt="<%= item.feed.title %> favicon" class="favicon">
<% end %>
<%= item.feed.title || "(Untitled)" %>
</a>
</td>
<td>
<td class="date align-middle">
<%= if item.date do %>
<% {:ok, date} = Timex.format(item.date, "{YYYY}-{0M}-{0D} {0h12}:{m} {AM}") %>
<%= date %>
<% end %>
</td>
<td class="py-0 align-middle">
<%= if item.read do %>
<%= form_tag Routes.item_path(@conn, :unread, item.id), method: :post do %>
<input type="hidden" name="redirect" value="<%= current_path(@conn) %>">
<%= submit "Unread", class: "btn btn-sm btn-secondary" %>
<% end %>
<% else %>
<%= form_tag Routes.item_path(@conn, :read, item.id), method: :post do %>
<input type="hidden" name="redirect" value="<%= current_path(@conn) %>">
<%= submit "Read", class: "btn btn-sm btn-secondary" %>
<% end %>
<% end %>
</td>
</tr>
<% end %>
</tbody>

View File

@ -16,6 +16,10 @@
<% end %>
</div>
<article class="mt-4">
<%= raw(@item.content) %>
<article class="item-content mt-4">
<%= if @item.content_type in [nil, "text/html"] do %>
<%= raw(@item.content) %>
<% else %>
<pre class="raw-content"><%= raw(@item.content) %></pre>
<% end %>
</article>

View File

@ -5,15 +5,14 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
<title>Frenzy</title>
<title><%= title(assigns) %></title>
<%= csrf_meta_tag() %>
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/assets/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/assets/app.js") %>"></script>
</head>
<body>
<header>
<main class="main mt-4" role="main">
<div class="container">
<label class="sidebar-toggle" for="show-sidebar">
@ -49,7 +48,7 @@
<li class="nav-item">
<details open="">
<summary>
<a href="<%= Routes.group_path(@conn, :show, group.id) %>"><%= group.title %></a>
<a href="<%= Routes.group_path(@conn, :show, group.id) %>" class="nav-link"><%= group.title %></a>
</summary>
<ul class="nav flex-column">
<%= for feed <- group.feeds do %>
@ -86,7 +85,5 @@
<div class="col sidebar-background"><label for="show-sidebar"></label></div>
</div>
</div>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>

View File

@ -1,24 +1,25 @@
<h1>Login</h1>
<%= form_tag Routes.login_path(@conn, :login_post), method: :post do %>
<%= if @continue do %>
<input type="hidden" name="continue" value="<%= @continue %>">
<% end %>
<div class="form-group row">
<div class="row mb-2">
<label for="username" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">
<input type="text" name="username" id="username" class="form-control">
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<label for="password" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-10">
<input type="password" name="password" id="password" class="form-control">
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<div class="col-sm-10">
<%= submit "Log In", class: "btn btn-primary" %>
</div>
</div>
<% end %>
<%= if @oidc_enabled? do %>
<a href="<%= Routes.login_path(@conn, :ueberauth_request, "oidc") %>">Log In with OIDC</a>
<% end %>

View File

@ -3,16 +3,16 @@
<a href="<%= Routes.pipeline_path(@conn, :edit, @pipeline.id) %>" class="btn btn-primary">Edit UI</a>
<%= form_tag Routes.pipeline_path(@conn, :update, @pipeline.id), method: :put do %>
<div class="form-group row">
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for="name">Name</label>
<div class="col-sm-10">
<%= text_input :pipeline, :name, value: @name, placeholder: "My New Pipeline", class: "form-control" %>
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<%= textarea :pipeline, :stages, value: @stages_json, class: "form-control", rows: 15, style: "font-family: monospace;" %>
</div>
<div class="form-group row">
<div class="row mb-2">
<div class="col-sm-10">
<%= submit "Edit Pipeline", class: "btn btn-primary" %>
</div>

View File

@ -1,14 +1,14 @@
<%= form_for @changeset, Routes.pipeline_path(@conn, :create), fn form -> %>
<div class="form-group row">
<div class="row mb-2">
<label class="col-sm-2 col-form-label" for="name">Name</label>
<div class="col-sm-10">
<%= text_input form, :name, placeholder: "My New Pipeline", class: "form-control" %>
</div>
</div>
<div class="form-group row">
<div class="row mb-2">
<%= textarea form, :stages, class: "form-control", rows: 15, style: "font-family: monospace;" %>
</div>
<div class="form-group row">
<div class="row mb-2">
<div class="col-sm-10">
<%= submit "Create Pipeline", class: "btn btn-primary" %>
</div>

View File

@ -1,3 +1,7 @@
defmodule FrenzyWeb.AccountView do
use FrenzyWeb, :view
def title(:show, _) do
"Account Settings"
end
end

View File

@ -1,6 +1,7 @@
defmodule FrenzyWeb.FeedView do
use FrenzyWeb, :view
alias Frenzy.Feed
import Phoenix.Controller, only: [current_path: 1]
@spec feed_site_url(feed :: Feed.t()) :: String.t()
def feed_site_url(%Feed{site_url: site_url}) when is_binary(site_url) do
@ -10,4 +11,12 @@ defmodule FrenzyWeb.FeedView do
def feed_site_url(%Feed{feed_url: feed_url}) do
URI.merge(feed_url, "/") |> to_string()
end
def title(:show, %{feed: %Feed{title: title}}) do
title
end
def title(:edit, %{feed: %Feed{title: title}}) do
"Edit #{title}"
end
end

View File

@ -1,3 +1,20 @@
defmodule FrenzyWeb.GroupView do
use FrenzyWeb, :view
import Phoenix.Controller, only: [current_path: 1]
def title(:index, _) do
"Groups"
end
def title(:show, %{group: %Frenzy.Group{title: title}}) do
title
end
def title(:edit, %{group: %Frenzy.Group{title: title}}) do
"Edit #{title}"
end
def title(:read, %{group: %Frenzy.Group{title: title}}) do
"Read #{title}"
end
end

View File

@ -1,3 +1,7 @@
defmodule FrenzyWeb.ItemView do
use FrenzyWeb, :view
end
def title(:show, %{item: %Frenzy.Item{title: title}, feed: %Frenzy.Feed{title: feed}}) do
"#{title} | #{feed}"
end
end

View File

@ -6,4 +6,30 @@ defmodule FrenzyWeb.LayoutView do
def user_groups(user) do
Repo.all(from g in Group, where: g.user_id == ^user.id, preload: [:feeds])
end
def title(%{live_module: module} = assigns) do
try do
"#{module.title(assigns)} | Frenzy"
rescue
_ ->
"Frenzy"
end
end
def title(assigns) do
vm = Phoenix.Controller.view_module(assigns[:conn])
if function_exported?(vm, :title, 2) do
action = Phoenix.Controller.action_name(assigns[:conn])
try do
"#{vm.title(action, assigns)} | Frenzy"
rescue
_ ->
"Frenzy"
end
else
"Frenzy"
end
end
end

View File

@ -1,3 +1,7 @@
defmodule FrenzyWeb.LoginView do
use FrenzyWeb, :view
def title(_, _) do
"Login"
end
end

View File

@ -1,3 +1,19 @@
defmodule FrenzyWeb.PipelineView do
use FrenzyWeb, :view
def title(:index, _) do
"Pipelines"
end
def title(:new, _) do
"Add Pipeline"
end
def title(:show, %{pipeline: %Frenzy.Pipeline{name: name}}) do
name
end
def title(:edit, %{pipeline: %Frenzy.Pipeline{name: name}}) do
"Edit #{name}"
end
end

35
mix.exs
View File

@ -20,7 +20,7 @@ defmodule Frenzy.MixProject do
def application do
[
mod: {Frenzy.Application, []},
extra_applications: [:logger, :runtime_tools, :httpoison, :readability]
extra_applications: [:logger, :runtime_tools, :readability]
]
end
@ -33,28 +33,33 @@ defmodule Frenzy.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.5.2"},
{:phoenix, "~> 1.6.11"},
{:phoenix_pubsub, "~> 2.0"},
{:phoenix_ecto, "~> 4.1"},
{:ecto_sql, "~> 3.4"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.6"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.11"},
{:phoenix_html, "~> 3.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.1"},
{:httpoison, "~> 1.6.2"},
{:hackney, "~> 1.16"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"},
{:castore, "~> 0.1.12"},
{:mint, "~> 1.4"},
{:tesla, "~> 1.4.0"},
{:feed_parser,
git: "https://git.shadowfacts.net/shadowfacts/feed_parser.git", branch: "master"},
{:timex, "~> 3.6"},
{:readability, git: "https://github.com/shadowfacts/readability.git", branch: "master"},
{:readability,
git: "https://git.shadowfacts.net/shadowfacts/readability.git", branch: "master"},
{:bcrypt_elixir, "~> 2.0"},
{:dialyxir, "~> 1.0.0-rc.6", only: :dev, runtime: false},
{:xml_builder, "~> 2.1.1"},
{:floki, "~> 0.23"},
{:phoenix_live_view,
git: "https://github.com/phoenixframework/phoenix_live_view", branch: "master"}
{:floki, "~> 0.30"},
{:phoenix_live_view, "~> 0.17.5"},
{:gemini, git: "https://git.shadowfacts.net/shadowfacts/gemini-ex.git", branch: "main"},
{:sentry, "~> 8.0"},
{:ueberauth, "~> 0.10"},
{:ueberauth_oidc, "~> 0.1"}
]
end
@ -68,7 +73,9 @@ defmodule Frenzy.MixProject do
[
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate", "test"]
test: ["ecto.create --quiet", "ecto.migrate", "test"],
"assets.deploy": ["cmd --cd assets node build.js --deploy", "phx.digest"],
sentry_recompile: ["deps.compile sentry --force", "compile"]
]
end
end

View File

@ -1,52 +1,66 @@
%{
"basic_auth": {:hex, :basic_auth, "2.2.4", "d8c748237870dd1df3bc5c0f1ab4f1fad6270c75472d7e62b19302ec59e92a79", [:mix], [{:plug, "~> 0.14 or ~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "2.0.1", "1061e2114aaac554c12e5c1e4608bf4aadaca839f30d1b85224272facd5e6427", [:make, :mix], [{:comeonin, "~> 5.1", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "64f174c76ea5edfcc471dfb7762280a20e29fe446baa02dc75c7d14251581e93"},
"certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"},
"castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.1.1", "0abd6bae41acc01c369bb3eafe46399f301bf4e1bacebafdb89252bbb8a1a32d", [:mix], [], "hexpm", "b77aef9eb7ec7a4c01cc3d0683332796052ab71067d858d5dacde967427de0a3"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"},
"cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"},
"cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"},
"db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "49496d63267bc1a4614ffd5f67c45d9fc3ea62701a6797975bc98bc156d2763f"},
"ecto": {:hex, :ecto, "3.4.4", "a2c881e80dc756d648197ae0d936216c0308370332c5e77a2325a10293eef845", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4bd3ad62abc3b21fb629f0f7a3dab23a192fca837d257dd08449fba7373561"},
"ecto_sql": {:hex, :ecto_sql, "3.4.4", "d28bac2d420f708993baed522054870086fd45016a9d09bb2cd521b9c48d32ea", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "edb49af715dd72f213b66adfd0f668a43c17ed510b5d9ac7528569b23af57fe8"},
"ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"},
"ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"},
"elixir_make": {:hex, :elixir_make, "0.5.2", "96a28c79f5b8d34879cd95ebc04d2a0d678cfbbd3e74c43cb63a76adf0ee8054", [:mix], [], "hexpm", "382eeea8e02dfe6c468f6729b6cf20fe5b14390671d38c7363e59621c7ab4efc"},
"erlex": {:hex, :erlex, "0.2.4", "23791959df45fe8f01f388c6f7eb733cc361668cbeedd801bf491c55a029917b", [:mix], [], "hexpm", "4a12ebc7cd8f24f2d0fce93d279fa34eb5068e0e885bb841d558c4d83c52c439"},
"feed_parser": {:git, "https://git.shadowfacts.net/shadowfacts/feed_parser.git", "8c42d4587328698e8d29d2ad562e478abb146f75", [branch: "master"]},
"fiet": {:git, "https://github.com/shadowfacts/fiet.git", "bf117bc30a6355a189d05a562127cfaf9e0187ae", [branch: "master"]},
"esbuild": {:hex, :esbuild, "0.5.0", "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5"},
"feed_parser": {:git, "https://git.shadowfacts.net/shadowfacts/feed_parser.git", "943f4fdea7445547a9bc7aa33893a0442be36c68", [branch: "master"]},
"file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm", "0d50da6b04c58e101a3793b1600f9a03b86e3a8057b192ac1766013d35706fa6"},
"floki": {:hex, :floki, "0.23.0", "956ab6dba828c96e732454809fb0bd8d43ce0979b75f34de6322e73d4c917829", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e680b5ef0b61ce02faa7137db8d1714903a5552be4c89fb57293b8770e7f49c2"},
"floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"},
"gemini": {:git, "https://git.shadowfacts.net/shadowfacts/gemini-ex.git", "cc6f4e04374d163438faae1b12b54809bdfb7f4d", [branch: "main"]},
"gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm", "e0b8598e802676c81e66b061a2148c37c03886b24a3ca86a1f98ed40693b94b3"},
"hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"},
"html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm", "3e3d7156a272950373ce5a4018b1490bea26676f8d6a7d409f6fac8568b8cb9a"},
"httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"},
"idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"},
"jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
"jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
"mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
"phoenix": {:hex, :phoenix, "1.5.3", "bfe0404e48ea03dfe17f141eff34e1e058a23f15f109885bbdcf62be303b49ff", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e16febeb9640d8b33895a691a56481464b82836d338bb3a23125cd7b6157c25"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"},
"phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"},
"openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.6.12", "f8f8ac077600f84419806dd53114b2e77aedde7a502e74181a7d886355aa0643", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d6cf5583c9c20f7103c40e6014ef802d96553b8e5d6585ad6e627bd5ddb0d12"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.0", "3bb31a9fbd40ffe8652e60c8660dffd72dd231efcdf49b744fb75b9ef7db5dd2", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b61c87a23fabcd85ff369fb9c041d9c01787d210322749026f56a69a914b7503"},
"phoenix_live_view": {:git, "https://github.com/phoenixframework/phoenix_live_view", "a622fed5e496561eb05a7fce5423d238c2597142", [branch: "master"]},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
"plug": {:hex, :plug, "1.10.2", "0079345cfdf9e17da3858b83eb46bc54beb91554c587b96438f55c1477af5a86", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7898d0eb4767efb3b925fd7f9d1870d15e66e9c33b89c58d8d2ad89aa75ab3c1"},
"plug_cowboy": {:hex, :plug_cowboy, "2.2.2", "7a09aa5d10e79b92d332a288f21cc49406b1b994cbda0fde76160e7f4cc890ea", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e82364b29311dbad3753d588febd7e5ef05062cd6697d8c231e0e007adab3727"},
"plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.17.11", "205f6aa5405648c76f2abcd57716f42fc07d8f21dd8ea7b262dd12b324b50c95", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7177791944b7f90ed18f5935a6a5f07f760b36f7b3bdfb9d28c57440a3c43f99"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"},
"plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"},
"postgrex": {:hex, :postgrex, "0.15.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"},
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
"readability": {:git, "https://github.com/shadowfacts/readability.git", "71fa17caaf8103ef213e2c7dde4b447a48669122", [branch: "master"]},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.17.2", "a3ec9e3239d9b33f1e5841565c4eb200055c52cc0757a22b63ca2d529bbe764c", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "80a918a9e9531d39f7bd70621422f3ebc93c01618c645f2d91306f50041ed90c"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"readability": {:git, "https://git.shadowfacts.net/shadowfacts/readability.git", "75404b197d67e118a6575ee9b39a9ae2ac3c2dcc", [branch: "master"]},
"saxy": {:hex, :saxy, "0.6.0", "cdb2f2fcd8133d1f3f8b0cf6a131ee1ca348dca613de266e9a239db850c4a093", [:mix], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},
"sentry": {:hex, :sentry, "8.0.5", "5ca922b9238a50c7258b52f47364b2d545beda5e436c7a43965b34577f1ef61f", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "4972839fdbf52e886d7b3e694c8adf421f764f2fa79036b88fb4742049bd4b7c"},
"socket": {:hex, :socket, "0.3.13", "98a2ab20ce17f95fb512c5cadddba32b57273e0d2dba2d2e5f976c5969d0c632", [:mix], [], "hexpm", "f82ea9833ef49dde272e6568ab8aac657a636acb4cf44a7de8a935acb8957c2e"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
"tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"},
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"},
"tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cf1345dfbce6acdfd4e23cbb36e96e53d1981bc89181cd0b936f4f398f4c0b78"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"},
"ueberauth": {:hex, :ueberauth, "0.10.5", "806adb703df87e55b5615cf365e809f84c20c68aa8c08ff8a416a5a6644c4b02", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3efd1f31d490a125c7ed453b926f7c31d78b97b8a854c755f5c40064bf3ac9e1"},
"ueberauth_oidc": {:hex, :ueberauth_oidc, "0.1.7", "d610cbe5ef09881dff52126906b130307adcf02791ce158c1847fd50949b283a", [:mix], [{:httpoison, "~> 1.8", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:openid_connect, "~> 0.2.2", [hex: :openid_connect, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "34d612f66a5425af4142d6c9dece887c60188c31e1dc113e5ee8cecdc6c5e8a9"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"xml_builder": {:hex, :xml_builder, "2.1.1", "2d6d665f09cf1319e3e1c46035755271b414d99ad8615d0bd6f337623e0c885b", [:mix], [], "hexpm", "214c16caa77e66bf0c6b74099a7059ee00de8fd07728d2a3dc32afe344a7452b"},
}

View File

@ -0,0 +1,9 @@
defmodule Frenzy.Repo.Migrations.ItemAddContentType do
use Ecto.Migration
def change do
alter table(:items) do
add :content_type, :string, default: nil
end
end
end

View File

@ -0,0 +1,7 @@
defmodule Frenzy.Repo.Migrations.CreatItemUniqueIndex do
use Ecto.Migration
def change do
create unique_index(:items, [:feed_id, :guid], name: :items_feed_guid_index)
end
end

View File

@ -0,0 +1,9 @@
defmodule Frenzy.Repo.Migrations.ChangeItemTitleText do
use Ecto.Migration
def change do
alter table(:items) do
modify :title, :text
end
end
end

View File

@ -0,0 +1,11 @@
defmodule Frenzy.Repo.Migrations.ChangeItemFieldsToText do
use Ecto.Migration
def change do
alter table(:items) do
modify :guid, :text
modify :url, :text
modify :creator, :text
end
end
end

View File

@ -0,0 +1,9 @@
defmodule Frenzy.Repo.Migrations.UserAddOidcSubject do
use Ecto.Migration
def change do
alter table(:users) do
add :oidc_subject, :string, default: nil
end
end
end

Binary file not shown.

Binary file not shown.

View File

@ -1,543 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<!--
2014-7-1: Created.
-->
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>
Created by FontForge 20120731 at Tue Jul 1 20:39:22 2014
By P.J. Onori
Created by P.J. Onori with FontForge 2.0 (http://fontforge.sf.net)
</metadata>
<defs>
<font id="open-iconic" horiz-adv-x="800" >
<font-face
font-family="Icons"
font-weight="400"
font-stretch="normal"
units-per-em="800"
panose-1="2 0 5 3 0 0 0 0 0 0"
ascent="800"
descent="0"
bbox="-0.5 -101 802 800.126"
underline-thickness="50"
underline-position="-100"
unicode-range="U+E000-E0DE"
/>
<missing-glyph />
<glyph glyph-name="" unicode="&#xe000;"
d="M300 700h500v-700h-500v100h400v500h-400v100zM400 500l200 -150l-200 -150v100h-400v100h400v100z" />
<glyph glyph-name="1" unicode="&#xe001;"
d="M300 700h500v-700h-500v100h400v500h-400v100zM200 500v-100h400v-100h-400v-100l-200 150z" />
<glyph glyph-name="2" unicode="&#xe002;"
d="M350 700c193 0 350 -157 350 -350v-50h100l-200 -200l-200 200h100v50c0 138 -112 250 -250 250s-250 -112 -250 -250c0 193 157 350 350 350z" />
<glyph glyph-name="3" unicode="&#xe003;"
d="M450 700c193 0 350 -157 350 -350c0 138 -112 250 -250 250s-250 -112 -250 -250v-50h100l-200 -200l-200 200h100v50c0 193 157 350 350 350z" />
<glyph glyph-name="4" unicode="&#xe004;"
d="M0 700h800v-100h-800v100zM100 500h600v-100h-600v100zM0 300h800v-100h-800v100zM100 100h600v-100h-600v100z" />
<glyph glyph-name="5" unicode="&#xe005;"
d="M0 700h800v-100h-800v100zM0 500h600v-100h-600v100zM0 300h800v-100h-800v100zM0 100h600v-100h-600v100z" />
<glyph glyph-name="6" unicode="&#xe006;"
d="M0 700h800v-100h-800v100zM200 500h600v-100h-600v100zM0 300h800v-100h-800v100zM200 100h600v-100h-600v100z" />
<glyph glyph-name="7" unicode="&#xe007;"
d="M400 700c75 0 146 -23 206 -59l-75 -225l-322 234c57 31 122 50 191 50zM125 588l191 -138l-310 -222c-4 24 -6 47 -6 72c0 114 49 215 125 288zM688 575c69 -72 112 -168 112 -275c0 -35 -8 -68 -16 -100h-218zM216 253l112 -347c-128 23 -232 109 -287 222zM372 100
h372c-64 -109 -177 -185 -310 -197z" />
<glyph glyph-name="8" unicode="&#xe008;" horiz-adv-x="600"
d="M200 800h100v-500h200l-247 -300l-253 300h200v500z" />
<glyph glyph-name="9" unicode="&#xe009;"
d="M400 800c221 0 400 -179 400 -400s-179 -400 -400 -400s-400 179 -400 400s179 400 400 400zM300 700v-300h-200l300 -300l300 300h-200v300h-200z" />
<glyph glyph-name="a" unicode="&#xe00a;"
d="M400 800c221 0 400 -179 400 -400s-179 -400 -400 -400s-400 179 -400 400s179 400 400 400zM400 700l-300 -300l300 -300v200h300v200h-300v200z" />
<glyph glyph-name="b" unicode="&#xe00b;"
d="M400 800c221 0 400 -179 400 -400s-179 -400 -400 -400s-400 179 -400 400s179 400 400 400zM400 700v-200h-300v-200h300v-200l300 300z" />
<glyph glyph-name="c" unicode="&#xe00c;"
d="M400 800c221 0 400 -179 400 -400s-179 -400 -400 -400s-400 179 -400 400s179 400 400 400zM400 700l-300 -300h200v-300h200v300h200z" />
<glyph glyph-name="d" unicode="&#xe00d;"
d="M300 600v-200h500v-100h-500v-200l-300 247z" />
<glyph glyph-name="e" unicode="&#xe00e;"
d="M500 600l300 -247l-300 -253v200h-500v100h500v200z" />
<glyph glyph-name="f" unicode="&#xe00f;" horiz-adv-x="600"
d="M200 800h200v-500h200l-297 -300l-303 300h200v500z" />
<glyph glyph-name="10" unicode="&#xe010;"
d="M300 700v-200h500v-200h-500v-200l-300 297z" />
<glyph glyph-name="11" unicode="&#xe011;"
d="M500 700l300 -297l-300 -303v200h-500v200h500v200z" />
<glyph glyph-name="12" unicode="&#xe012;" horiz-adv-x="600"
d="M297 800l303 -300h-200v-500h-200v500h-200z" />
<glyph glyph-name="13" unicode="&#xe013;" horiz-adv-x="600"
d="M247 800l253 -300h-200v-500h-100v500h-200z" />
<glyph glyph-name="14" unicode="&#xe014;"
d="M400 800h100v-800h-100v800zM200 700h100v-600h-100v600zM600 600h100v-400h-100v400zM0 500h100v-200h-100v200z" />
<glyph glyph-name="15" unicode="&#xe015;"
d="M116 600l72 -72c-54 -54 -88 -126 -88 -209s34 -159 88 -213l-72 -72c-72 72 -116 175 -116 285s44 209 116 281zM684 600c72 -72 116 -171 116 -281s-44 -213 -116 -285l-72 72c54 54 88 130 88 213s-34 155 -88 209zM259 460l69 -72c-18 -18 -28 -41 -28 -69
s10 -54 28 -72l-69 -72c-36 36 -59 89 -59 144s23 105 59 141zM541 459c36 -36 59 -85 59 -140s-23 -108 -59 -144l-69 72c18 18 28 44 28 72s-10 51 -28 69z" />
<glyph glyph-name="16" unicode="&#xe016;" horiz-adv-x="400"
d="M200 800c110 0 200 -90 200 -200s-90 -200 -200 -200s-200 90 -200 200s90 200 200 200zM100 319c31 -11 65 -19 100 -19s68 8 100 19v-319l-100 100l-100 -100v319z" />
<glyph glyph-name="17" unicode="&#xe017;"
d="M400 800c220 0 400 -180 400 -400s-180 -400 -400 -400s-400 180 -400 400s180 400 400 400zM400 700c-166 0 -300 -134 -300 -300c0 -66 21 -126 56 -175l419 419c-49 35 -109 56 -175 56zM644 575l-419 -419c49 -35 109 -56 175 -56c166 0 300 134 300 300
c0 66 -21 126 -56 175z" />
<glyph glyph-name="18" unicode="&#xe018;"
d="M0 700h100v-600h700v-100h-800v700zM500 700h200v-500h-200v500zM200 500h200v-300h-200v300z" />
<glyph glyph-name="19" unicode="&#xe019;"
d="M397 800c13 1 23 -4 34 -13c2 -2 214 -254 241 -287h128v-100h-100v-366c0 -18 -16 -34 -34 -34h-532c-18 0 -34 16 -34 34v366h-100v100h128l234 281c9 11 22 18 35 19zM400 672l-144 -172h288zM250 300c-28 0 -50 -22 -50 -50v-100c0 -28 22 -50 50 -50s50 22 50 50
v100c0 28 -22 50 -50 50zM550 300c-28 0 -50 -22 -50 -50v-100c0 -28 22 -50 50 -50s50 22 50 50v100c0 28 -22 50 -50 50z" />
<glyph glyph-name="1a" unicode="&#xe01a;"
d="M9 700h682c6 0 9 -4 9 -10v-190h100v-200h-100v-191c0 -6 -3 -9 -9 -9h-682c-6 0 -9 3 -9 9v582c0 6 3 9 9 9zM100 600v-400h500v400h-500z" />
<glyph glyph-name="1b" unicode="&#xe01b;"
d="M9 700h682c6 0 9 -4 9 -10v-190h100v-200h-100v-191c0 -6 -3 -9 -9 -9h-682c-6 0 -9 3 -9 9v582c0 6 3 9 9 9z" />
<glyph glyph-name="1c" unicode="&#xe01c;"
d="M92 650c0 23 19 50 45 50h3h5h5h500c28 0 50 -22 50 -50s-22 -50 -50 -50h-50v-141c9 -17 120 -231 166 -309c16 -26 34 -61 34 -106c0 -39 -15 -77 -41 -103h-3c-26 -25 -62 -41 -100 -41h-512c-39 0 -77 15 -103 41s-41 64 -41 103c0 46 18 80 34 106
c46 78 157 292 166 309v141h-50c-2 0 -6 -1 -8 -1c-28 0 -50 23 -50 51zM500 600h-200v-162l-6 -10s-63 -123 -119 -228h450c-56 105 -119 228 -119 228l-6 10v162z" />
<glyph glyph-name="1d" unicode="&#xe01d;"
d="M400 800c110 0 200 -90 200 -200c0 -104 52 -198 134 -266c41 -34 66 -82 66 -134h-800c0 52 25 100 66 134c82 68 134 162 134 266c0 110 90 200 200 200zM300 100h200c0 -55 -45 -100 -100 -100s-100 45 -100 100z" />
<glyph glyph-name="1e" unicode="&#xe01e;" horiz-adv-x="600"
d="M150 800h50l350 -250l-225 -147l225 -153l-350 -250h-50v250l-75 -75l-75 75l150 150l-150 150l75 75l75 -75v250zM250 650v-200l150 100zM250 350v-200l150 100z" />
<glyph glyph-name="1f" unicode="&#xe01f;"
d="M0 800h500c110 0 200 -90 200 -200c0 -47 -17 -91 -44 -125c85 -40 144 -125 144 -225c0 -138 -112 -250 -250 -250h-550v100c55 0 100 45 100 100v400c0 55 -45 100 -100 100v100zM300 700v-200h100c55 0 100 45 100 100s-45 100 -100 100h-100zM300 400v-300h150
c83 0 150 67 150 150s-67 150 -150 150h-150z" />
<glyph glyph-name="20" unicode="&#xe020;" horiz-adv-x="600"
d="M300 800v-300h200l-300 -500v300h-200z" />
<glyph glyph-name="21" unicode="&#xe021;"
d="M100 800h300v-300l100 100l100 -100v300h50c28 0 50 -22 50 -50v-550h-550c-28 0 -50 -22 -50 -50s22 -50 50 -50h550v-100h-550c-83 0 -150 67 -150 150v550l3 19c8 39 39 70 78 78z" />
<glyph glyph-name="22" unicode="&#xe022;" horiz-adv-x="400"
d="M0 800h400v-800l-200 200l-200 -200v800z" />
<glyph glyph-name="23" unicode="&#xe023;"
d="M0 800h800v-100h-800v100zM0 600h300v-103h203v103h297v-591c0 -6 -3 -9 -9 -9h-782c-6 0 -9 3 -9 9v591z" />
<glyph glyph-name="24" unicode="&#xe024;"
d="M300 800h200c55 0 100 -45 100 -100v-100h191c6 0 9 -3 9 -9v-241c0 -28 -22 -50 -50 -50h-700c-28 0 -50 22 -50 50v241c0 6 3 9 9 9h191v100c0 55 45 100 100 100zM300 700v-100h200v100h-200zM0 209c16 -6 32 -9 50 -9h700c18 0 34 3 50 9v-200c0 -6 -3 -9 -9 -9h-782
c-6 0 -9 3 -9 9v200z" />
<glyph glyph-name="25" unicode="&#xe025;" horiz-adv-x="600"
d="M300 800c58 0 110 -16 147 -53s53 -89 53 -147h-100c0 39 -11 61 -25 75s-36 25 -75 25c-35 0 -55 -10 -72 -31s-28 -55 -28 -94c0 -51 20 -107 28 -175h172v-100h-178c-14 -60 -49 -127 -113 -200h491v-100h-600v122l16 12c69 69 95 121 106 166h-122v100h125
c-8 50 -25 106 -25 175c0 58 16 114 50 156c34 43 88 69 150 69z" />
<glyph glyph-name="26" unicode="&#xe026;"
d="M34 700h4h3h4h5h700c28 0 50 -22 50 -50v-700c0 -28 -22 -50 -50 -50h-700c-28 0 -50 22 -50 50v700v2c0 20 15 42 34 48zM150 600c-28 0 -50 -22 -50 -50s22 -50 50 -50s50 22 50 50s-22 50 -50 50zM350 600c-28 0 -50 -22 -50 -50s22 -50 50 -50h300c28 0 50 22 50 50
s-22 50 -50 50h-300zM100 400v-400h600v400h-600z" />
<glyph glyph-name="27" unicode="&#xe027;"
d="M744 797l6 -3l44 -44c4 -4 3 -8 0 -12l-266 -375l-15 -13l-25 -12c-23 72 -78 127 -150 150l12 25l13 15l375 266zM266 400c74 0 134 -60 134 -134c0 -147 -119 -266 -266 -266c-48 0 -95 12 -134 34c80 46 134 133 134 232c0 74 58 134 132 134z" />
<glyph glyph-name="28" unicode="&#xe028;"
d="M9 451c0 23 19 50 46 50c8 0 19 -3 26 -7l131 -66l29 22c-79 81 -1 250 118 250s197 -167 119 -250l28 -22l131 66c6 4 12 7 21 7c28 0 50 -22 50 -50c0 -17 -12 -37 -27 -45l-115 -56c9 -16 19 -33 25 -50h68c28 0 50 -22 50 -50s-22 -50 -50 -50h-50
c0 -23 -2 -45 -6 -66l78 -40c21 -5 37 -28 37 -49c0 -28 -22 -50 -50 -50c-10 0 -23 5 -31 11l-65 35c-24 -46 -62 -86 -103 -110c-35 19 -60 45 -60 72v135v4v5v6v5v5v87c0 28 -22 50 -50 50c-24 0 -45 -17 -50 -40c1 -3 1 -8 1 -11s0 -8 -1 -11v-82v-4v-5v-144
c0 -28 -24 -53 -59 -72c-41 25 -79 64 -103 110l-66 -35c-8 -6 -21 -11 -31 -11c-28 0 -50 22 -50 50c0 21 16 44 37 49l78 40c-4 21 -6 43 -6 66h-50h-5c-28 0 -50 22 -50 50c0 26 22 50 50 50h5h69c6 17 16 34 25 50l-116 56c-16 7 -28 27 -28 45z" />
<glyph glyph-name="29" unicode="&#xe029;"
d="M600 700h91c6 0 9 -3 9 -9v-582c0 -6 -3 -9 -9 -9h-91v600zM210 503l290 147v-500l-250 125v-3c-15 0 -25 -8 -28 -22l75 -178c11 -25 0 -58 -25 -69s-58 0 -69 25l-103 272h-91c-6 0 -9 3 -9 9v182c0 6 3 9 9 9h182z" />
<glyph glyph-name="2a" unicode="&#xe02a;"
d="M9 800h682c6 0 9 -3 9 -9v-782c0 -6 -3 -9 -9 -9h-682c-6 0 -9 3 -9 9v782c0 6 3 9 9 9zM100 700v-200h500v200h-500zM100 400v-100h100v100h-100zM300 400v-100h100v100h-100zM500 400v-300h100v300h-100zM100 200v-100h100v100h-100zM300 200v-100h100v100h-100z" />
<glyph glyph-name="2b" unicode="&#xe02b;"
d="M0 800h700v-200h-700v200zM0 500h700v-491c0 -6 -3 -9 -9 -9h-682c-6 0 -9 3 -9 9v491zM100 400v-100h100v100h-100zM300 400v-100h100v100h-100zM500 400v-100h100v100h-100zM100 200v-100h100v100h-100zM300 200v-100h100v100h-100z" />
<glyph glyph-name="2c" unicode="&#xe02c;"
d="M409 800h182c6 0 10 -4 12 -9l94 -182c2 -5 6 -9 12 -9h82c6 0 9 -3 9 -9v-582c0 -6 -3 -9 -9 -9h-782c-6 0 -9 3 -9 9v441c0 83 67 150 150 150h141c6 0 10 4 12 9l94 182c2 5 6 9 12 9zM150 500c-28 0 -50 -22 -50 -50s22 -50 50 -50s50 22 50 50s-22 50 -50 50z
M500 500c-110 0 -200 -90 -200 -200s90 -200 200 -200s200 90 200 200s-90 200 -200 200zM500 400c55 0 100 -45 100 -100s-45 -100 -100 -100s-100 45 -100 100s45 100 100 100z" />
<glyph glyph-name="2d" unicode="&#xe02d;"
d="M0 600h800l-400 -400z" />
<glyph glyph-name="2e" unicode="&#xe02e;" horiz-adv-x="400"
d="M400 800v-800l-400 400z" />
<glyph glyph-name="2f" unicode="&#xe02f;" horiz-adv-x="400"
d="M0 800l400 -400l-400 -400v800z" />
<glyph glyph-name="30" unicode="&#xe030;"
d="M400 600l400 -400h-800z" />
<glyph glyph-name="31" unicode="&#xe031;"
d="M0 550c0 23 20 50 46 50h3h5h4h200c17 0 37 -13 44 -28l38 -72h444c14 0 19 -12 15 -25l-81 -250c-4 -13 -21 -25 -35 -25h-350c-14 0 -30 12 -34 25c-27 83 -54 167 -81 250l-10 25h-150c-2 0 -5 -1 -7 -1c-28 0 -51 23 -51 51zM358 100c28 0 50 -22 50 -50
s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM658 100c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50z" />
<glyph glyph-name="32" unicode="&#xe032;"
d="M0 700h500v-100h-300v-300h-100l-100 -100v500zM300 500h500v-500l-100 100h-400v400z" />
<glyph glyph-name="33" unicode="&#xe033;"
d="M641 700l143 -141l-493 -493c-71 76 -146 148 -219 222l-72 71l141 141c50 -51 101 -101 153 -150c116 117 234 231 347 350z" />
<glyph glyph-name="34" unicode="&#xe034;"
d="M150 600l250 -250l250 250l150 -150l-400 -400l-400 400z" />
<glyph glyph-name="35" unicode="&#xe035;" horiz-adv-x="600"
d="M400 800l150 -150l-250 -250l250 -250l-150 -150l-400 400z" />
<glyph glyph-name="36" unicode="&#xe036;" horiz-adv-x="600"
d="M150 800l400 -400l-400 -400l-150 150l250 250l-250 250z" />
<glyph glyph-name="37" unicode="&#xe037;"
d="M400 600l400 -400l-150 -150l-250 250l-250 -250l-150 150z" />
<glyph glyph-name="38" unicode="&#xe038;"
d="M400 800c221 0 400 -179 400 -400s-179 -400 -400 -400s-400 179 -400 400s179 400 400 400zM600 622l-250 -250l-100 100l-72 -72l172 -172l322 322z" />
<glyph glyph-name="39" unicode="&#xe039;"
d="M400 800c221 0 400 -179 400 -400s-179 -400 -400 -400s-400 179 -400 400s179 400 400 400zM250 622l-72 -72l150 -150l-150 -150l72 -72l150 150l150 -150l72 72l-150 150l150 150l-72 72l-150 -150z" />
<glyph glyph-name="3a" unicode="&#xe03a;"
d="M350 800c28 0 50 -22 50 -50v-50h75c14 0 25 -11 25 -25v-75h-300v75c0 14 11 25 25 25h75v50c0 28 22 50 50 50zM25 700h75v-200h500v200h75c14 0 25 -11 25 -25v-650c0 -14 -11 -25 -25 -25h-650c-14 0 -25 11 -25 25v650c0 14 11 25 25 25z" />
<glyph glyph-name="3b" unicode="&#xe03b;"
d="M400 800c220 0 400 -180 400 -400s-180 -400 -400 -400s-400 180 -400 400s180 400 400 400zM400 700c-166 0 -300 -134 -300 -300s134 -300 300 -300s300 134 300 300s-134 300 -300 300zM350 600h100v-181c23 -24 47 -47 72 -69l-72 -72c-27 30 -55 59 -84 88l-16 12
v222z" />
<glyph glyph-name="3c" unicode="&#xe03c;"
d="M450 800c138 0 250 -112 250 -250v-50c58 -21 100 -85 100 -150c0 -18 -3 -34 -9 -50h-191v50c0 83 -67 150 -150 150s-150 -67 -150 -150v-50h-272c-17 30 -28 63 -28 100c0 110 90 200 200 200c23 114 129 200 250 200zM434 400h3h4c3 0 6 1 9 1c28 0 50 -22 50 -50v-1
v-150h150l-200 -200l-200 200h150v150v2c0 20 15 42 34 48z" />
<glyph glyph-name="3d" unicode="&#xe03d;"
d="M450 800c138 0 250 -112 250 -250v-50c58 -21 100 -85 100 -150c0 -18 -3 -34 -9 -50h-141l-200 200l-200 -200h-222c-17 30 -28 63 -28 100c0 110 90 200 200 200c23 114 129 200 250 200zM450 350l250 -250h-200v-50c0 -28 -22 -50 -50 -50s-50 22 -50 50v50h-200z" />
<glyph glyph-name="3e" unicode="&#xe03e;"
d="M450 700c138 0 250 -112 250 -250v-50c58 -21 100 -85 100 -150c0 -83 -67 -150 -150 -150h-450c-110 0 -200 90 -200 200s90 200 200 200c23 114 129 200 250 200z" />
<glyph glyph-name="3f" unicode="&#xe03f;"
d="M250 800c82 0 154 -40 200 -100c-143 0 -270 -85 -325 -209c-36 -10 -70 -25 -100 -47c-16 33 -25 67 -25 106c0 138 112 250 250 250zM450 600c138 0 250 -112 250 -250v-50c58 -21 100 -85 100 -150c0 -83 -67 -150 -150 -150h-450c-110 0 -200 90 -200 200
s90 200 200 200c23 114 129 200 250 200z" />
<glyph glyph-name="40" unicode="&#xe040;"
d="M500 700h100l-300 -600h-100zM100 600h100l-100 -200l100 -200h-100l-100 200zM600 600h100l100 -200l-100 -200h-100l100 200z" />
<glyph glyph-name="41" unicode="&#xe041;"
d="M350 800h100l50 -119l28 -12l119 50l72 -72l-50 -119l12 -28l119 -50v-100l-119 -50l-12 -28l50 -119l-72 -72l-119 50l-28 -12l-50 -119h-100l-50 119l-28 12l-119 -50l-72 72l50 119l-12 28l-119 50v100l119 50l12 28l-50 119l72 72l119 -50l28 12zM400 550
c-83 0 -150 -67 -150 -150s67 -150 150 -150s150 67 150 150s-67 150 -150 150z" />
<glyph glyph-name="42" unicode="&#xe042;"
d="M0 800h800v-200h-800v200zM200 500h400l-200 -200zM0 100h800v-100h-800v100z" />
<glyph glyph-name="43" unicode="&#xe043;"
d="M0 800h100v-800h-100v800zM600 800h200v-800h-200v800zM500 600v-400l-200 200z" />
<glyph glyph-name="44" unicode="&#xe044;"
d="M0 800h200v-800h-200v800zM700 800h100v-800h-100v800zM300 600l200 -200l-200 -200v400z" />
<glyph glyph-name="45" unicode="&#xe045;"
d="M0 800h800v-100h-800v100zM400 500l200 -200h-400zM0 200h800v-200h-800v200z" />
<glyph glyph-name="46" unicode="&#xe046;"
d="M150 700c83 0 150 -67 150 -150v-50h100v50c0 83 67 150 150 150s150 -67 150 -150s-67 -150 -150 -150h-50v-100h50c83 0 150 -67 150 -150s-67 -150 -150 -150s-150 67 -150 150v50h-100v-50c0 -83 -67 -150 -150 -150s-150 67 -150 150s67 150 150 150h50v100h-50
c-83 0 -150 67 -150 150s67 150 150 150zM150 600c-28 0 -50 -22 -50 -50s22 -50 50 -50h50v50c0 28 -22 50 -50 50zM550 600c-28 0 -50 -22 -50 -50v-50h50c28 0 50 22 50 50s-22 50 -50 50zM300 400v-100h100v100h-100zM150 200c-28 0 -50 -22 -50 -50s22 -50 50 -50
s50 22 50 50v50h-50zM500 200v-50c0 -28 22 -50 50 -50s50 22 50 50s-22 50 -50 50h-50z" />
<glyph glyph-name="47" unicode="&#xe047;"
d="M0 791c0 5 4 9 9 9h782c6 0 9 -4 9 -10v-790l-200 200h-591c-6 0 -9 3 -9 9v582z" />
<glyph glyph-name="48" unicode="&#xe048;"
d="M400 800c220 0 400 -180 400 -400s-180 -400 -400 -400s-400 180 -400 400s180 400 400 400zM400 700c-166 0 -300 -134 -300 -300s134 -300 300 -300s300 134 300 300s-134 300 -300 300zM600 600l-100 -300l-300 -100l100 300zM400 450c-28 0 -50 -22 -50 -50
s22 -50 50 -50s50 22 50 50s-22 50 -50 50z" />
<glyph glyph-name="49" unicode="&#xe049;"
d="M400 800c220 0 400 -180 400 -400s-180 -400 -400 -400s-400 180 -400 400s180 400 400 400zM400 700v-600c166 0 300 134 300 300s-134 300 -300 300z" />
<glyph glyph-name="4a" unicode="&#xe04a;"
d="M0 800h800v-100h-800v100zM0 600h500v-100h-500v100zM0 300h800v-100h-800v100zM0 100h600v-100h-600v100zM750 100c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50z" />
<glyph glyph-name="4b" unicode="&#xe04b;"
d="M25 700h750c14 0 25 -11 25 -25v-75h-800v75c0 14 11 25 25 25zM0 500h800v-375c0 -14 -11 -25 -25 -25h-750c-14 0 -25 11 -25 25v375zM100 300v-100h100v100h-100zM300 300v-100h100v100h-100z" />
<glyph glyph-name="4c" unicode="&#xe04c;"
d="M100 800h100v-100h450l100 100l50 -50l-100 -100v-450h100v-100h-100v-100h-100v100h-500v500h-100v100h100v100zM200 600v-350l350 350h-350zM600 550l-350 -350h350v350z" />
<glyph glyph-name="4d" unicode="&#xe04d;"
d="M400 800c220 0 400 -180 400 -400s-180 -400 -400 -400s-400 180 -400 400s180 400 400 400zM400 700c-166 0 -300 -134 -300 -300s134 -300 300 -300s300 134 300 300s-134 300 -300 300zM400 600c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50z
M200 452c0 20 15 42 34 48h3h3h8c12 0 28 -7 36 -16l91 -90l25 6c55 0 100 -45 100 -100s-45 -100 -100 -100s-100 45 -100 100l6 25l-90 91c-9 8 -16 24 -16 36zM550 500c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50z" />
<glyph glyph-name="4e" unicode="&#xe04e;"
d="M300 800h200v-300h200l-300 -300l-300 300h200v300zM0 100h800v-100h-800v100z" />
<glyph glyph-name="4f" unicode="&#xe04f;"
d="M0 800h800v-100h-800v100zM400 600l300 -300h-200v-300h-200v300h-200z" />
<glyph glyph-name="50" unicode="&#xe050;"
d="M200 700h600v-600h-600l-200 300zM350 622l-72 -72l150 -150l-150 -150l72 -72l150 150l150 -150l72 72l-150 150l150 150l-72 72l-150 -150z" />
<glyph glyph-name="51" unicode="&#xe051;"
d="M400 700c220 0 400 -180 400 -400h-100c0 166 -134 300 -300 300s-300 -134 -300 -300h-100c0 220 180 400 400 400zM341 491l59 -88l59 88c81 -25 141 -101 141 -191c0 -110 -90 -200 -200 -200s-200 90 -200 200c0 90 60 166 141 191z" />
<glyph glyph-name="52" unicode="&#xe052;"
d="M0 800h300v-400h400v-400h-700v800zM400 800l300 -300h-300v300zM100 600v-100h100v100h-100zM100 400v-100h100v100h-100zM100 200v-100h400v100h-400z" />
<glyph glyph-name="53" unicode="&#xe053;" horiz-adv-x="600"
d="M200 700h100v-100h75c30 0 58 -6 81 -22s44 -44 44 -78v-100h-100v94c-4 3 -13 6 -25 6h-250c-14 0 -25 -11 -25 -25v-50c0 -15 20 -40 34 -44l257 -65c66 -16 109 -73 109 -141v-50c0 -68 -57 -125 -125 -125h-75v-100h-100v100h-75c-30 0 -58 6 -81 22s-44 44 -44 78
v100h100v-94c4 -3 13 -6 25 -6h250c14 0 25 11 25 25v50c0 15 -20 40 -34 44l-257 65c-66 16 -109 73 -109 141v50c0 68 57 125 125 125h75v100z" />
<glyph glyph-name="54" unicode="&#xe054;"
d="M0 700h300v-300l-300 -300v600zM500 700h300v-300l-300 -300v600z" />
<glyph glyph-name="55" unicode="&#xe055;"
d="M300 700v-600h-300v300zM800 700v-600h-300v300z" />
<glyph glyph-name="56" unicode="&#xe056;"
d="M300 700v-100c-111 0 -200 -89 -200 -200h200v-300h-300v300c0 165 135 300 300 300zM800 700v-100c-111 0 -200 -89 -200 -200h200v-300h-300v300c0 165 135 300 300 300z" />
<glyph glyph-name="57" unicode="&#xe057;"
d="M0 700h300v-300c0 -165 -135 -300 -300 -300v100c111 0 200 89 200 200h-200v300zM500 700h300v-300c0 -165 -135 -300 -300 -300v100c111 0 200 89 200 200h-200v300z" />
<glyph glyph-name="58" unicode="&#xe058;" horiz-adv-x="600"
d="M300 800l34 -34c11 -11 266 -270 266 -488c0 -165 -135 -300 -300 -300s-300 135 -300 300c0 218 255 477 266 488zM150 328c-28 0 -50 -22 -50 -50c0 -110 90 -200 200 -200c28 0 50 22 50 50s-22 50 -50 50c-55 0 -100 45 -100 100c0 28 -22 50 -50 50z" />
<glyph glyph-name="59" unicode="&#xe059;"
d="M400 800l400 -500h-800zM0 200h800v-200h-800v200z" />
<glyph glyph-name="5a" unicode="&#xe05a;" horiz-adv-x="600"
d="M300 800l300 -300h-600zM0 300h600l-300 -300z" />
<glyph glyph-name="5b" unicode="&#xe05b;"
d="M0 500h200v-200h-200v200zM300 500h200v-200h-200v200zM600 500h200v-200h-200v200z" />
<glyph glyph-name="5c" unicode="&#xe05c;"
d="M0 700h800v-100l-400 -200l-400 200v100zM0 500l400 -200l400 200v-400h-800v400z" />
<glyph glyph-name="5d" unicode="&#xe05d;"
d="M400 800l400 -200v-600h-800v600zM400 688l-300 -150v-188l300 -150l300 150v188zM200 500h400v-100l-200 -100l-200 100v100z" />
<glyph glyph-name="5e" unicode="&#xe05e;"
d="M600 700c69 0 134 -19 191 -50l-16 -106c-49 35 -109 56 -175 56c-131 0 -240 -84 -281 -200h331l-16 -100h-334c0 -36 8 -68 19 -100h297l-16 -100h-222c55 -61 133 -100 222 -100c78 0 147 30 200 78v-122c-59 -35 -127 -56 -200 -56c-147 0 -274 82 -344 200h-256
l19 100h197c-8 32 -16 66 -16 100h-200l25 100h191c45 172 198 300 384 300z" />
<glyph glyph-name="5f" unicode="&#xe05f;"
d="M0 700h700v-100h-700v100zM0 500h500v-100h-500v100zM0 300h800v-100h-800v100zM0 100h100v-100h-100v100zM200 100h100v-100h-100v100zM400 100h100v-100h-100v100z" />
<glyph glyph-name="60" unicode="&#xe060;"
d="M0 800h800v-100h-800v100zM200 600h400l-200 -200zM0 200h800v-200h-800v200z" />
<glyph glyph-name="61" unicode="&#xe061;"
d="M0 800h100v-800h-100v800zM600 800h200v-800h-200v800zM200 600l200 -200l-200 -200v400z" />
<glyph glyph-name="62" unicode="&#xe062;"
d="M0 800h200v-800h-200v800zM700 800h100v-800h-100v800zM600 600v-400l-200 200z" />
<glyph glyph-name="63" unicode="&#xe063;"
d="M0 800h800v-200h-800v200zM400 400l200 -200h-400zM0 100h800v-100h-800v100z" />
<glyph glyph-name="64" unicode="&#xe064;"
d="M0 800h200v-100h-100v-600h600v100h100v-200h-800v800zM400 800h400v-400l-150 150l-250 -250l-100 100l250 250z" />
<glyph glyph-name="65" unicode="&#xe065;"
d="M403 700c247 0 397 -300 397 -300s-150 -300 -397 -300c-253 0 -403 300 -403 300s150 300 403 300zM400 600c-110 0 -200 -90 -200 -200s90 -200 200 -200s200 90 200 200s-90 200 -200 200zM400 500c10 0 19 -3 28 -6c-16 -8 -28 -24 -28 -44c0 -28 22 -50 50 -50
c20 0 36 12 44 28c3 -9 6 -18 6 -28c0 -55 -45 -100 -100 -100s-100 45 -100 100s45 100 100 100z" />
<glyph glyph-name="66" unicode="&#xe066;" horiz-adv-x="900"
d="M331 700h3h3c3 1 7 1 10 1c12 0 29 -8 37 -17l94 -93l66 65c57 57 155 57 212 0c58 -58 58 -154 0 -212l-65 -66l93 -94c10 -8 18 -25 18 -38c0 -28 -22 -50 -50 -50c-13 0 -32 9 -40 20l-62 65l-381 -381h-269v272l375 381l-63 63c-9 8 -16 24 -16 36c0 20 16 42 35 48z
M447 481l-313 -315l128 -132l316 316z" />
<glyph glyph-name="67" unicode="&#xe067;"
d="M0 800h300v-400h400v-400h-700v800zM400 800l300 -300h-300v300z" />
<glyph glyph-name="68" unicode="&#xe068;"
d="M200 800c0 0 200 -100 200 -300s-298 -302 -200 -500c0 0 -200 100 -200 300s300 300 200 500zM500 500c0 0 200 -100 200 -300c0 -150 -60 -200 -100 -200h-300c0 200 300 300 200 500z" />
<glyph glyph-name="69" unicode="&#xe069;"
d="M0 800h100v-800h-100v800zM200 800h300v-100h300l-200 -203l200 -197h-400v100h-200v400z" />
<glyph glyph-name="6a" unicode="&#xe06a;" horiz-adv-x="400"
d="M150 800h150l-100 -200h200l-150 -300h150l-300 -300l-100 300h134l66 200h-200z" />
<glyph glyph-name="6b" unicode="&#xe06b;"
d="M0 800h300v-100h500v-100h-800v200zM0 500h800v-450c0 -28 -22 -50 -50 -50h-700c-28 0 -50 22 -50 50v450z" />
<glyph glyph-name="6c" unicode="&#xe06c;"
d="M150 800c83 0 150 -67 150 -150c0 -66 -41 -121 -100 -141v-118c15 5 33 9 50 9h200c28 0 50 22 50 50v59c-59 20 -100 75 -100 141c0 83 67 150 150 150s150 -67 150 -150c0 -66 -41 -121 -100 -141v-59c0 -82 -68 -150 -150 -150h-200c-14 0 -25 -7 -34 -16
c50 -24 84 -74 84 -134c0 -83 -67 -150 -150 -150s-150 67 -150 150c0 66 41 121 100 141v218c-59 20 -100 75 -100 141c0 83 67 150 150 150z" />
<glyph glyph-name="6d" unicode="&#xe06d;"
d="M0 800h400l-150 -150l150 -150l-100 -100l-150 150l-150 -150v400zM500 400l150 -150l150 150v-400h-400l150 150l-150 150z" />
<glyph glyph-name="6e" unicode="&#xe06e;"
d="M100 800l150 -150l150 150v-400h-400l150 150l-150 150zM400 400h400l-150 -150l150 -150l-100 -100l-150 150l-150 -150v400z" />
<glyph glyph-name="6f" unicode="&#xe06f;"
d="M400 800c221 0 400 -179 400 -400s-179 -400 -400 -400s-400 179 -400 400s179 400 400 400zM400 700c-56 0 -108 -17 -153 -44l22 -19c33 -18 13 -48 -13 -59c-30 -13 -77 10 -65 -41c13 -55 -27 -3 -47 -15c-42 -26 49 -152 31 -156l-59 34c-8 0 -13 -5 -16 -10
c1 -30 10 -57 19 -84c28 -11 77 -2 100 -25c47 -28 97 -115 75 -159c34 -13 68 -22 106 -22c101 0 193 48 247 125c3 24 -8 44 -50 44c-69 0 -156 13 -153 97c2 46 101 108 66 143c-30 30 12 39 12 66c0 37 -65 32 -69 50s20 36 41 56c-30 10 -60 19 -94 19zM631 591
c-38 -11 -94 -35 -87 -53c6 -15 52 -1 65 -13c11 -10 16 -59 44 -31l22 22v3c-11 26 -26 50 -44 72z" />
<glyph glyph-name="70" unicode="&#xe070;"
d="M703 800l97 -100l-400 -400l-100 100l-200 -203l-100 100l300 303l100 -100zM0 100h800v-100h-800v100z" />
<glyph glyph-name="71" unicode="&#xe071;"
d="M0 700h100v-100h-100v100zM200 700h100v-100h-100v100zM400 700h100v-100h-100v100zM600 700h100v-100h-100v100zM0 500h100v-100h-100v100zM200 500h100v-100h-100v100zM400 500h100v-100h-100v100zM600 500h100v-100h-100v100zM0 300h100v-100h-100v100zM200 300h100
v-100h-100v100zM400 300h100v-100h-100v100zM600 300h100v-100h-100v100zM0 100h100v-100h-100v100zM200 100h100v-100h-100v100zM400 100h100v-100h-100v100zM600 100h100v-100h-100v100z" />
<glyph glyph-name="72" unicode="&#xe072;"
d="M0 800h200v-200h-200v200zM300 800h200v-200h-200v200zM600 800h200v-200h-200v200zM0 500h200v-200h-200v200zM300 500h200v-200h-200v200zM600 500h200v-200h-200v200zM0 200h200v-200h-200v200zM300 200h200v-200h-200v200zM600 200h200v-200h-200v200z" />
<glyph glyph-name="73" unicode="&#xe073;"
d="M0 800h300v-300h-300v300zM500 800h300v-300h-300v300zM0 300h300v-300h-300v300zM500 300h300v-300h-300v300z" />
<glyph glyph-name="74" unicode="&#xe074;"
d="M19 800h662c11 0 19 -8 19 -19v-331c0 -28 -22 -50 -50 -50h-600c-28 0 -50 22 -50 50v331c0 11 8 19 19 19zM0 309c16 -6 32 -9 50 -9h600c18 0 34 3 50 9v-290c0 -11 -8 -19 -19 -19h-662c-11 0 -19 8 -19 19v290zM550 200c-28 0 -50 -22 -50 -50s22 -50 50 -50
s50 22 50 50s-22 50 -50 50z" />
<glyph glyph-name="75" unicode="&#xe075;"
d="M0 700h300v-100h-50c-28 0 -50 -22 -50 -50v-150h300v150c0 28 -22 50 -50 50h-50v100h300v-100h-50c-28 0 -50 -22 -50 -50v-400c0 -28 22 -50 50 -50h50v-100h-300v100h50c28 0 50 22 50 50v150h-300v-150c0 -28 22 -50 50 -50h50v-100h-300v100h50c28 0 50 22 50 50
v400c0 28 -22 50 -50 50h-50v100z" />
<glyph glyph-name="76" unicode="&#xe076;"
d="M400 700c165 0 300 -135 300 -300v-100h50c28 0 50 -22 50 -50v-200c0 -28 -22 -50 -50 -50h-100c-28 0 -50 22 -50 50v350c0 111 -89 200 -200 200s-200 -89 -200 -200v-350c0 -28 -22 -50 -50 -50h-100c-28 0 -50 22 -50 50v200c0 28 22 50 50 50h50v100
c0 165 135 300 300 300z" />
<glyph glyph-name="77" unicode="&#xe077;"
d="M0 500c0 109 91 200 200 200s200 -91 200 -200c0 109 91 200 200 200s200 -91 200 -200c0 -55 -23 -105 -59 -141l-341 -340l-341 340c-36 36 -59 86 -59 141z" />
<glyph glyph-name="78" unicode="&#xe078;"
d="M400 700l400 -300l-100 3v-403h-200v200h-200v-200h-200v400h-100z" />
<glyph glyph-name="79" unicode="&#xe079;"
d="M0 800h800v-800h-800v800zM100 700v-300l100 100l400 -400h100v100l-200 200l100 100l100 -100v300h-600z" />
<glyph glyph-name="7a" unicode="&#xe07a;"
d="M19 800h762c11 0 19 -8 19 -19v-762c0 -11 -8 -19 -19 -19h-762c-11 0 -19 8 -19 19v762c0 11 8 19 19 19zM100 600v-300h100l100 -100h200l100 100h100v300h-600z" />
<glyph glyph-name="7b" unicode="&#xe07b;"
d="M200 600c80 0 142 -56 200 -122c58 66 119 122 200 122c131 0 200 -101 200 -200s-69 -200 -200 -200c-81 0 -142 56 -200 122c-58 -66 -121 -122 -200 -122c-131 0 -200 101 -200 200s69 200 200 200zM200 500c-74 0 -100 -54 -100 -100s26 -100 100 -100
c42 0 88 47 134 100c-46 53 -92 100 -134 100zM600 500c-43 0 -88 -47 -134 -100c46 -53 91 -100 134 -100c74 0 100 54 100 100s-26 100 -100 100z" />
<glyph glyph-name="7c" unicode="&#xe07c;" horiz-adv-x="400"
d="M300 800c55 0 100 -45 100 -100s-45 -100 -100 -100s-100 45 -100 100s45 100 100 100zM150 550c83 0 150 -69 150 -150c0 -66 -100 -214 -100 -250c0 -28 22 -50 50 -50s50 22 50 50h100c0 -83 -67 -150 -150 -150s-150 64 -150 150s100 222 100 250s-22 50 -50 50
s-50 -22 -50 -50h-100c0 83 67 150 150 150z" />
<glyph glyph-name="7d" unicode="&#xe07d;"
d="M200 800h500v-100h-122c-77 -197 -156 -392 -234 -588l-6 -12h162v-100h-500v100h122c77 197 156 392 234 588l7 12h-163v100z" />
<glyph glyph-name="7e" unicode="&#xe07e;"
d="M0 700h800v-100h-800v100zM0 500h800v-100h-800v100zM0 300h800v-100h-800v100zM100 100h600v-100h-600v100z" />
<glyph glyph-name="7f" unicode="&#xe07f;"
d="M0 700h800v-100h-800v100zM0 500h800v-100h-800v100zM0 300h800v-100h-800v100zM0 100h600v-100h-600v100z" />
<glyph glyph-name="80" unicode="&#xe080;"
d="M0 700h800v-100h-800v100zM0 500h800v-100h-800v100zM0 300h800v-100h-800v100zM200 100h600v-100h-600v100z" />
<glyph glyph-name="81" unicode="&#xe081;"
d="M550 800c138 0 250 -112 250 -250s-112 -250 -250 -250c-16 0 -32 0 -47 3l-3 -3v-100h-200v-200h-300v200l303 303c-3 15 -3 31 -3 47c0 138 112 250 250 250zM600 700c-55 0 -100 -45 -100 -100s45 -100 100 -100s100 45 100 100s-45 100 -100 100z" />
<glyph glyph-name="82" unicode="&#xe082;"
d="M134 600h3h4h4h5h500c28 0 50 -22 50 -50v-350h100v-150c0 -28 -22 -50 -50 -50h-700c-28 0 -50 22 -50 50v150h100v350v2c0 20 15 42 34 48zM200 500v-300h100v-100h200v100h100v300h-400z" />
<glyph glyph-name="83" unicode="&#xe083;"
d="M0 800h400v-400h-400v400zM500 600h100v-400h-400v100h300v300zM700 400h100v-400h-400v100h300v300z" />
<glyph glyph-name="84" unicode="&#xe084;" horiz-adv-x="600"
d="M337 694c6 4 12 7 21 7c28 0 50 -22 50 -50c0 -17 -12 -37 -27 -45l-300 -150c-8 -6 -21 -11 -31 -11c-28 0 -50 22 -50 50c0 21 16 44 37 49zM437 544c6 4 12 7 21 7c28 0 50 -22 50 -50c0 -17 -12 -37 -27 -45l-400 -200c-8 -6 -21 -11 -31 -11c-28 0 -50 22 -50 50
c0 21 16 44 37 49zM437 344c6 4 12 7 21 7c28 0 50 -22 50 -50c0 -17 -12 -37 -27 -45l-106 -56c24 -4 43 -26 43 -50c0 -28 -23 -51 -51 -51c-2 0 -6 1 -8 1h-200c-26 1 -48 24 -48 50c0 16 12 36 26 44zM151 -50c0 23 20 50 46 50h3h4h5h100c28 0 50 -22 50 -50
s-22 -50 -50 -50h-100c-2 0 -6 -1 -8 -1c-28 0 -50 23 -50 51z" />
<glyph glyph-name="85" unicode="&#xe085;"
d="M199 800h100v-200h-200v100h100v100zM586 797h1c18 1 38 1 56 -3c36 -8 69 -26 97 -54c78 -78 78 -203 0 -281l-150 -150c-8 -13 -28 -24 -43 -24c-28 0 -50 22 -50 50c0 15 11 35 24 43l150 150c40 40 39 105 0 144c-41 41 -110 34 -144 0l-44 -44
c-8 -13 -27 -24 -42 -24c-28 0 -50 22 -50 50c0 15 11 35 24 43l43 44c32 33 72 53 128 56zM208 490c4 5 14 16 22 16h3c2 0 6 1 8 1c28 0 50 -22 50 -50c0 -11 -6 -27 -14 -35l-150 -150c-40 -40 -39 -105 0 -144c41 -41 110 -34 144 0l44 44c8 13 27 24 42 24
c28 0 50 -22 50 -50c0 -15 -11 -35 -24 -43l-43 -44c-22 -22 -48 -37 -75 -47c-70 -25 -151 -9 -207 47c-78 78 -78 203 0 281zM499 200h200v-100h-100v-100h-100v200z" />
<glyph glyph-name="86" unicode="&#xe086;"
d="M586 797c18 1 39 1 57 -3c36 -8 69 -26 97 -54c78 -78 78 -203 0 -281l-150 -150c-62 -62 -132 -81 -182 -78s-69 17 -84 25s-26 27 -26 44c0 28 22 51 50 51c8 0 19 -3 26 -7c0 0 15 -11 41 -13s62 3 106 47l150 150c40 40 39 105 0 144c-41 41 -110 34 -144 0
c-8 -13 -28 -24 -43 -24c-28 0 -50 22 -50 50c0 15 11 35 24 43c32 33 72 53 128 56zM386 566c50 -2 64 -17 85 -22s37 -28 37 -49c0 -28 -22 -50 -50 -50c-10 0 -23 5 -31 11c0 0 -19 9 -47 10s-63 -4 -103 -44l-150 -150c-40 -40 -39 -105 0 -144c41 -41 110 -34 144 0
c8 13 27 24 42 24c28 0 50 -22 50 -50c0 -15 -10 -35 -23 -43c-22 -22 -48 -37 -75 -47c-70 -25 -151 -9 -207 47c-78 78 -78 203 0 281l150 150c60 60 128 78 178 76z" />
<glyph glyph-name="87" unicode="&#xe087;"
d="M0 700h300v-300h-300v300zM400 700h400v-100h-400v100zM400 500h300v-100h-300v100zM0 300h300v-300h-300v300zM400 300h400v-100h-400v100zM400 100h300v-100h-300v100z" />
<glyph glyph-name="88" unicode="&#xe088;"
d="M50 700c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM200 700h600v-100h-600v100zM50 500c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM200 500h600v-100h-600v100zM50 300c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50
s22 50 50 50zM200 300h600v-100h-600v100zM50 100c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM200 100h600v-100h-600v100z" />
<glyph glyph-name="89" unicode="&#xe089;"
d="M800 800l-400 -800l-100 300l-300 100z" />
<glyph glyph-name="8a" unicode="&#xe08a;" horiz-adv-x="600"
d="M300 700c110 0 200 -90 200 -200v-100h100v-400h-600v400h100v100c0 110 90 200 200 200zM300 600c-56 0 -100 -44 -100 -100v-100h200v100c0 56 -44 100 -100 100z" />
<glyph glyph-name="8b" unicode="&#xe08b;" horiz-adv-x="600"
d="M300 800c110 0 200 -90 200 -200v-200h100v-400h-600v400h400v200c0 56 -44 100 -100 100s-100 -44 -100 -100h-100c0 110 90 200 200 200z" />
<glyph glyph-name="8c" unicode="&#xe08c;"
d="M400 700v-100c-111 0 -200 -89 -200 -200h100l-150 -200l-150 200h100c0 165 135 300 300 300zM650 600l150 -200h-100c0 -165 -135 -300 -300 -300v100c111 0 200 89 200 200h-100z" />
<glyph glyph-name="8d" unicode="&#xe08d;"
d="M100 800h600v-300h100l-150 -250l-150 250h100v200h-400v-100h-100v200zM150 550l150 -250h-100v-200h400v100h100v-200h-600v300h-100z" />
<glyph glyph-name="8e" unicode="&#xe08e;"
d="M600 700l200 -150l-200 -150v100h-500v-100h-100v100c0 55 45 100 100 100h500v100zM200 300v-100h500v100h100v-100c0 -55 -45 -100 -100 -100h-500v-100l-200 150z" />
<glyph glyph-name="8f" unicode="&#xe08f;" horiz-adv-x="900"
d="M350 800c193 0 350 -157 350 -350c0 -60 -17 -117 -44 -166c5 -3 12 -8 16 -12l100 -100c16 -16 30 -49 30 -72c0 -56 -46 -102 -102 -102c-23 0 -56 14 -72 30l-100 100c-4 3 -9 9 -12 13c-49 -26 -107 -41 -166 -41c-193 0 -350 157 -350 350s157 350 350 350zM350 200
c142 0 250 108 250 250c0 139 -111 250 -250 250s-250 -111 -250 -250s111 -250 250 -250z" />
<glyph glyph-name="90" unicode="&#xe090;" horiz-adv-x="600"
d="M300 800c166 0 300 -134 300 -300c0 -200 -300 -500 -300 -500s-300 300 -300 500c0 166 134 300 300 300zM300 700c-110 0 -200 -90 -200 -200s90 -200 200 -200s200 90 200 200s-90 200 -200 200z" />
<glyph glyph-name="91" unicode="&#xe091;" horiz-adv-x="900"
d="M0 800h800v-541c1 -3 1 -8 1 -11s0 -7 -1 -10v-238h-800v800zM495 250c0 26 22 50 50 50h5h150v400h-600v-600h600v100h-150h-5c-28 0 -50 22 -50 50zM350 600c83 0 150 -67 150 -150c0 -100 -150 -250 -150 -250s-150 150 -150 250c0 83 67 150 150 150zM350 500
c-28 0 -50 -22 -50 -50s22 -50 50 -50s50 22 50 50s-22 50 -50 50z" />
<glyph glyph-name="92" unicode="&#xe092;" horiz-adv-x="600"
d="M0 700h200v-600h-200v600zM400 700h200v-600h-200v600z" />
<glyph glyph-name="93" unicode="&#xe093;" horiz-adv-x="600"
d="M0 700l600 -300l-600 -300v600z" />
<glyph glyph-name="94" unicode="&#xe094;" horiz-adv-x="600"
d="M300 700c166 0 300 -134 300 -300s-134 -300 -300 -300s-300 134 -300 300s134 300 300 300z" />
<glyph glyph-name="95" unicode="&#xe095;"
d="M400 700v-600l-400 300zM400 400l400 300v-600z" />
<glyph glyph-name="96" unicode="&#xe096;"
d="M0 700l400 -300l-400 -300v600zM400 100v600l400 -300z" />
<glyph glyph-name="97" unicode="&#xe097;"
d="M0 700h200v-600h-200v600zM200 400l500 300v-600z" />
<glyph glyph-name="98" unicode="&#xe098;"
d="M0 700l500 -300l-500 -300v600zM500 100v600h200v-600h-200z" />
<glyph glyph-name="99" unicode="&#xe099;" horiz-adv-x="600"
d="M0 700h600v-600h-600v600z" />
<glyph glyph-name="9a" unicode="&#xe09a;"
d="M200 800h400v-200h200v-400h-200v-200h-400v200h-200v400h200v200z" />
<glyph glyph-name="9b" unicode="&#xe09b;"
d="M0 700h800v-100h-800v100zM0 403h800v-100h-800v100zM0 103h800v-100h-800v100z" />
<glyph glyph-name="9c" unicode="&#xe09c;" horiz-adv-x="600"
d="M278 700c7 2 13 4 22 4c55 0 100 -45 100 -100v-4v-200c0 -55 -45 -100 -100 -100s-100 45 -100 100v200v2c0 44 35 88 78 98zM34 500h4h3c3 0 6 1 9 1c28 0 50 -22 50 -50v-1v-50c0 -111 89 -200 200 -200s200 89 200 200v50c0 28 22 50 50 50s50 -22 50 -50v-50
c0 -148 -109 -270 -250 -294v-106h50c55 0 100 -45 100 -100h-400c0 55 45 100 100 100h50v106c-141 24 -250 146 -250 294v50v2c0 20 15 42 34 48z" />
<glyph glyph-name="9d" unicode="&#xe09d;"
d="M0 500h800v-200h-800v200z" />
<glyph glyph-name="9e" unicode="&#xe09e;"
d="M34 700h4h3h4h5h700c28 0 50 -22 50 -50v-500c0 -28 -22 -50 -50 -50h-250v-100h100c55 0 100 -45 100 -100h-600c0 55 45 100 100 100h100v100h-250c-28 0 -50 22 -50 50v500v2c0 20 15 42 34 48zM100 600v-400h600v400h-600z" />
<glyph glyph-name="9f" unicode="&#xe09f;"
d="M272 700c-14 -40 -22 -83 -22 -128c0 -221 179 -400 400 -400c45 0 88 8 128 22c-53 -158 -202 -272 -378 -272c-221 0 -400 179 -400 400c0 176 114 325 272 378z" />
<glyph glyph-name="a0" unicode="&#xe0a0;"
d="M350 700l150 -150h-100v-150h150v100l150 -150l-150 -150v100h-150v-150h100l-150 -150l-150 150h100v150h-150v-100l-150 150l150 150v-100h150v150h-100z" />
<glyph glyph-name="a1" unicode="&#xe0a1;"
d="M800 800v-550c0 -83 -67 -150 -150 -150s-150 67 -150 150s67 150 150 150c17 0 35 -4 50 -9v206c-201 -6 -327 -27 -400 -50v-397c0 -83 -67 -150 -150 -150s-150 67 -150 150s67 150 150 150c17 0 35 -4 50 -9v409s100 100 600 100z" />
<glyph glyph-name="a2" unicode="&#xe0a2;" horiz-adv-x="700"
d="M499 700c51 0 102 -20 141 -59c78 -78 78 -203 0 -281l-250 -244c-48 -48 -127 -48 -175 0s-48 127 0 175l96 97l69 -69l-90 -94l-7 -3c-10 -10 -10 -28 0 -38s28 -10 38 0l250 247c37 40 39 102 0 141s-104 40 -144 0l-278 -275c-66 -69 -68 -179 0 -247
c69 -69 181 -69 250 0l9 12l116 113l69 -69l-125 -125c-107 -107 -281 -107 -388 0s-107 281 0 388l278 272c39 39 90 59 141 59z" />
<glyph glyph-name="a3" unicode="&#xe0a3;"
d="M600 800l200 -200l-100 -100l-200 200zM400 600l200 -200l-400 -400h-200v200z" />
<glyph glyph-name="a4" unicode="&#xe0a4;"
d="M550 800c83 0 150 -90 150 -200s-67 -200 -150 -200c-22 0 -40 8 -59 19c6 26 9 52 9 81c0 84 -27 158 -72 212c27 52 71 88 122 88zM250 700c83 0 150 -90 150 -200s-67 -200 -150 -200s-150 90 -150 200s67 200 150 200zM725 384c44 -22 75 -66 75 -118v-166h-200v66
c0 50 -17 96 -44 134c66 2 126 33 169 84zM75 284c45 -53 106 -84 175 -84s130 31 175 84c44 -22 75 -66 75 -118v-166h-500v166c0 52 31 96 75 118z" />
<glyph glyph-name="a5" unicode="&#xe0a5;"
d="M400 800c110 0 200 -112 200 -250s-90 -250 -200 -250s-200 112 -200 250s90 250 200 250zM191 300c54 -61 128 -100 209 -100s155 39 209 100c106 -5 191 -92 191 -200v-100h-800v100c0 108 85 195 191 200z" />
<glyph glyph-name="a6" unicode="&#xe0a6;" horiz-adv-x="600"
d="M19 800h462c11 0 19 -8 19 -19v-762c0 -11 -8 -19 -19 -19h-462c-11 0 -19 8 -19 19v762c0 11 8 19 19 19zM100 700v-500h300v500h-300zM250 150c-28 0 -50 -22 -50 -50s22 -50 50 -50s50 22 50 50s-22 50 -50 50z" />
<glyph glyph-name="a7" unicode="&#xe0a7;"
d="M350 800c17 0 34 -1 50 -3v-397l-297 297c63 64 150 103 247 103zM500 694c169 -25 300 -168 300 -344c0 -193 -157 -350 -350 -350c-85 0 -161 31 -222 81l272 272v341zM91 562l237 -234l-212 -212c-70 55 -116 138 -116 234c0 84 35 158 91 212z" />
<glyph glyph-name="a8" unicode="&#xe0a8;"
d="M92 650c0 23 20 50 46 50h3h4h5h400c28 0 50 -22 50 -50s-22 -50 -50 -50h-50v-200h100c55 0 100 -45 100 -100h-300v-300l-56 -100l-44 100v300h-300c0 55 45 100 100 100h100v200h-50c-2 0 -6 -1 -8 -1c-28 0 -50 23 -50 51z" />
<glyph glyph-name="a9" unicode="&#xe0a9;"
d="M400 800c221 0 400 -179 400 -400s-179 -400 -400 -400s-400 179 -400 400s179 400 400 400zM300 600v-400l300 200z" />
<glyph glyph-name="aa" unicode="&#xe0aa;"
d="M300 800h200v-300h300v-200h-300v-300h-200v300h-300v200h300v300z" />
<glyph glyph-name="ab" unicode="&#xe0ab;"
d="M300 800h100v-400h-100v400zM172 656l62 -78l-40 -31c-58 -46 -94 -117 -94 -197c0 -139 111 -250 250 -250s250 111 250 250c0 80 -39 151 -97 197l-37 31l62 78l38 -31c82 -64 134 -164 134 -275c0 -193 -157 -350 -350 -350s-350 157 -350 350c0 111 53 211 134 275z
" />
<glyph glyph-name="ac" unicode="&#xe0ac;"
d="M200 800h400v-200h-400v200zM9 500h782c6 0 9 -3 9 -9v-282c0 -6 -3 -9 -9 -9h-91v200h-600v-200h-91c-6 0 -9 3 -9 9v282c0 6 3 9 9 9zM200 300h400v-300h-400v300z" />
<glyph glyph-name="ad" unicode="&#xe0ad;"
d="M0 700h100v-700h-100v700zM700 700h100v-700h-100v700zM200 600h200v-100h-200v100zM300 400h200v-100h-200v100zM400 200h200v-100h-200v100z" />
<glyph glyph-name="ae" unicode="&#xe0ae;"
d="M325 700c42 -141 87 -280 131 -419c29 74 59 148 88 222c30 -57 58 -114 87 -172h169v-100h-231l-13 28c-37 -92 -74 -184 -112 -275c-38 129 -79 257 -119 385c-42 -133 -83 -267 -125 -400c-28 88 -56 175 -84 262h-116v100h188l9 -34l3 -6c42 137 83 273 125 409z" />
<glyph glyph-name="af" unicode="&#xe0af;"
d="M200 600c0 57 43 100 100 100s100 -43 100 -100c0 -28 -18 -48 -28 -72c-3 -6 -3 -16 -3 -28h231v-231c12 0 22 0 28 3c24 10 44 28 72 28c57 0 100 -43 100 -100s-43 -100 -100 -100c-28 0 -48 18 -72 28c-6 3 -16 3 -28 3v-231h-231c0 12 0 22 3 28c10 24 28 44 28 72
c0 57 -43 100 -100 100s-100 -43 -100 -100c0 -28 18 -48 28 -72c3 -6 3 -16 3 -28h-231v600h231c0 12 0 22 -3 28c-10 24 -28 44 -28 72z" />
<glyph glyph-name="b0" unicode="&#xe0b0;" horiz-adv-x="500"
d="M247 700c84 0 148 -20 191 -59s59 -93 59 -141c0 -117 -69 -181 -119 -225s-81 -67 -81 -150v-25h-100v25c0 117 65 181 115 225s85 67 85 150c0 25 -8 48 -28 66s-56 34 -122 34s-97 -18 -116 -37s-27 -43 -31 -69l-100 12c5 38 19 88 59 128s103 66 188 66zM197 0h100
v-100h-100v100z" />
<glyph glyph-name="b1" unicode="&#xe0b1;"
d="M450 800c138 0 250 -112 250 -250v-50c58 -21 100 -85 100 -150c0 -69 -48 -127 -112 -144c-22 55 -75 94 -138 94c-20 0 -39 -5 -56 -12c-17 64 -75 112 -144 112s-127 -48 -144 -112c-17 7 -36 12 -56 12c-37 0 -71 -12 -97 -34c-33 36 -53 82 -53 134
c0 110 90 200 200 200c23 114 129 200 250 200zM334 300h4h3c3 0 6 1 9 1c28 0 50 -22 50 -50v-1v-200c0 -28 -22 -50 -50 -50s-50 22 -50 50v200v2c0 20 15 42 34 48zM134 200h4h3c3 0 6 1 9 1c28 0 50 -22 50 -50v-1v-100c0 -28 -22 -50 -50 -50s-50 22 -50 50v100v2
c0 20 15 42 34 48zM534 200h3h4c3 0 6 1 9 1c28 0 50 -22 50 -50v-1v-100c0 -28 -22 -50 -50 -50s-50 22 -50 50v100v2c0 20 15 42 34 48z" />
<glyph glyph-name="b2" unicode="&#xe0b2;"
d="M600 800l200 -150l-200 -150v100h-50l-153 -191l175 -206l6 -3h22v100l200 -150l-200 -150v100h-25c-35 0 -56 12 -78 38l-166 190l-153 -190c-22 -27 -43 -38 -78 -38h-100v100h100l166 206l-163 191l-3 3h-100v100h100c34 0 56 -12 78 -38l153 -178l141 178
c22 27 43 38 78 38h50v100z" />
<glyph glyph-name="b3" unicode="&#xe0b3;"
d="M400 800c110 0 209 -47 281 -119l119 119v-300h-300l109 109c-54 55 -126 91 -209 91c-166 0 -300 -134 -300 -300s134 -300 300 -300c83 0 158 34 212 88l72 -72c-72 -72 -174 -116 -284 -116c-220 0 -400 180 -400 400s180 400 400 400z" />
<glyph glyph-name="b4" unicode="&#xe0b4;"
d="M400 800h400v-400l-166 166l-400 -400l166 -166h-400v400l166 -166l400 400z" />
<glyph glyph-name="b5" unicode="&#xe0b5;" horiz-adv-x="600"
d="M250 800l250 -300h-200v-200h200l-250 -300l-250 300h200v200h-200z" />
<glyph glyph-name="b6" unicode="&#xe0b6;"
d="M300 600v-200h200v200l300 -250l-300 -250v200h-200v-200l-300 250z" />
<glyph glyph-name="b7" unicode="&#xe0b7;"
d="M0 800c441 0 800 -359 800 -800h-200c0 333 -267 600 -600 600v200zM0 500c275 0 500 -225 500 -500h-200c0 167 -133 300 -300 300v200zM0 200c110 0 200 -90 200 -200h-200v200z" />
<glyph glyph-name="b8" unicode="&#xe0b8;"
d="M100 800c386 0 700 -314 700 -700h-100c0 332 -268 600 -600 600v100zM100 600c276 0 500 -224 500 -500h-100c0 222 -178 400 -400 400v100zM100 400c165 0 300 -135 300 -300h-100c0 111 -89 200 -200 200v100zM100 200c55 0 100 -45 100 -100s-45 -100 -100 -100
s-100 45 -100 100s45 100 100 100z" />
<glyph glyph-name="b9" unicode="&#xe0b9;"
d="M300 800h400c55 0 100 -45 100 -100v-200h-400v150c0 28 -22 50 -50 50s-50 -22 -50 -50v-250h400v-300c0 -55 -45 -100 -100 -100h-500c-55 0 -100 45 -100 100v200h100v-150c0 -28 22 -50 50 -50s50 22 50 50v550c0 55 45 100 100 100z" />
<glyph glyph-name="ba" unicode="&#xe0ba;"
d="M75 700h225v-100h-200v-500h400v100h100v-125c0 -41 -34 -75 -75 -75h-450c-41 0 -75 34 -75 75v550c0 41 34 75 75 75zM600 700l200 -200l-200 -200v100h-200c-94 0 -173 -65 -194 -153c23 199 189 353 394 353v100z" />
<glyph glyph-name="bb" unicode="&#xe0bb;"
d="M500 700l300 -284l-300 -316v200h-100c-200 0 -348 -102 -400 -300c0 295 100 500 500 500v200z" />
<glyph glyph-name="bc" unicode="&#xe0bc;"
d="M381 791l19 9l19 -9c127 -53 253 -108 381 -160v-31c0 -166 -67 -313 -147 -419c-40 -53 -83 -97 -125 -128s-82 -53 -128 -53s-86 22 -128 53s-85 75 -125 128c-80 107 -147 253 -147 419v31c128 52 254 107 381 160zM400 100v591l-294 -122c8 -126 58 -243 122 -328
c35 -46 73 -86 106 -110s62 -31 66 -31z" />
<glyph glyph-name="bd" unicode="&#xe0bd;"
d="M600 800h100v-800h-100v800zM400 700h100v-700h-100v700zM200 500h100v-500h-100v500zM0 300h100v-300h-100v300z" />
<glyph glyph-name="be" unicode="&#xe0be;"
d="M300 800h100v-200h200l100 -100l-100 -100h-200v-400h-100v500h-200l-100 100l100 100h200v100z" />
<glyph glyph-name="bf" unicode="&#xe0bf;"
d="M200 800h100v-600h200l-250 -200l-250 200h200v600zM400 800h200v-100h-200v100zM400 600h300v-100h-300v100zM400 400h400v-100h-400v100z" />
<glyph glyph-name="c0" unicode="&#xe0c0;"
d="M200 800h100v-600h200l-250 -200l-250 200h200v600zM400 800h400v-100h-400v100zM400 600h300v-100h-300v100zM400 400h200v-100h-200v100z" />
<glyph glyph-name="c1" unicode="&#xe0c1;"
d="M75 700h650c41 0 75 -34 75 -75v-550c0 -41 -34 -75 -75 -75h-650c-41 0 -75 34 -75 75v550c0 41 34 75 75 75zM100 600v-100h100v100h-100zM300 600v-100h400v100h-400zM100 400v-100h100v100h-100zM300 400v-100h400v100h-400zM100 200v-100h100v100h-100zM300 200
v-100h400v100h-400z" />
<glyph glyph-name="c2" unicode="&#xe0c2;"
d="M400 800l100 -300h300l-250 -200l100 -300l-250 200l-250 -200l100 300l-250 200h300z" />
<glyph glyph-name="c3" unicode="&#xe0c3;"
d="M400 800c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM150 700c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM650 700c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM400 600c110 0 200 -90 200 -200
s-90 -200 -200 -200s-200 90 -200 200s90 200 200 200zM50 450c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM750 450c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM150 200c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50
s22 50 50 50zM650 200c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50zM400 100c28 0 50 -22 50 -50s-22 -50 -50 -50s-50 22 -50 50s22 50 50 50z" />
<glyph glyph-name="c4" unicode="&#xe0c4;"
d="M34 800h632c18 0 34 -16 34 -34v-732c0 -18 -16 -34 -34 -34h-632c-18 0 -34 16 -34 34v732c0 18 16 34 34 34zM100 700v-500h500v500h-500zM350 150c-38 0 -63 -42 -44 -75s69 -33 88 0s-6 75 -44 75z" />
<glyph glyph-name="c5" unicode="&#xe0c5;"
d="M0 800h300l500 -500l-300 -300l-500 500v300zM200 700c-55 0 -100 -45 -100 -100s45 -100 100 -100s100 45 100 100s-45 100 -100 100z" />
<glyph glyph-name="c6" unicode="&#xe0c6;"
d="M0 600h200l300 -300l-200 -200l-300 300v200zM340 600h160l300 -300l-200 -200l-78 78l119 122zM150 500c-28 0 -50 -22 -50 -50s22 -50 50 -50s50 22 50 50s-22 50 -50 50z" />
<glyph glyph-name="c7" unicode="&#xe0c7;"
d="M400 800c220 0 400 -180 400 -400s-180 -400 -400 -400s-400 180 -400 400s180 400 400 400zM400 700c-166 0 -300 -134 -300 -300s134 -300 300 -300s300 134 300 300s-134 300 -300 300zM400 600c110 0 200 -90 200 -200s-90 -200 -200 -200s-200 90 -200 200
s90 200 200 200zM400 500c-56 0 -100 -44 -100 -100s44 -100 100 -100s100 44 100 100s-44 100 -100 100z" />
<glyph glyph-name="c8" unicode="&#xe0c8;"
d="M0 700h559l-100 -100h-359v-500h500v159l100 100v-359h-700v700zM700 700l100 -100l-400 -400l-200 200l100 100l100 -100z" />
<glyph glyph-name="c9" unicode="&#xe0c9;"
d="M9 800h782c6 0 9 -3 9 -9v-782c0 -6 -3 -9 -9 -9h-782c-6 0 -9 3 -9 9v782c0 6 3 9 9 9zM150 722l-72 -72l100 -100l-100 -100l72 -72l172 172zM400 500v-100h300v100h-300z" />
<glyph glyph-name="ca" unicode="&#xe0ca;"
d="M0 800h800v-200h-50c0 55 -45 100 -100 100h-150v-550c0 -28 22 -50 50 -50h50v-100h-400v100h50c28 0 50 22 50 50v550h-150c-55 0 -100 -45 -100 -100h-50v200z" />
<glyph glyph-name="cb" unicode="&#xe0cb;"
d="M0 700h100v-400h-100v400zM200 700h350c21 0 39 -13 47 -31c0 0 103 -291 103 -319s-22 -50 -50 -50h-150c-28 0 -50 -25 -50 -50s39 -158 47 -184s-5 -55 -31 -63s-52 5 -66 31s-109 219 -128 238s-44 28 -72 28v400z" />
<glyph glyph-name="cc" unicode="&#xe0cc;"
d="M400 666c10 19 28 32 47 34l19 -3c26 -8 39 -37 31 -63s-47 -159 -47 -184s22 -50 50 -50h150c28 0 50 -22 50 -50s-103 -319 -103 -319c-8 -18 -26 -31 -47 -31h-350v400c28 0 53 9 72 28s114 212 128 238zM0 400h100v-400h-100v400z" />
<glyph glyph-name="cd" unicode="&#xe0cd;"
d="M200 700h300v-100h-100v-6c25 -4 50 -8 72 -16l-34 -94c-28 11 -58 16 -88 16c-139 0 -250 -111 -250 -250s111 -250 250 -250s250 111 250 250c0 31 -5 60 -16 88l91 37c14 -38 25 -81 25 -125c0 -193 -157 -350 -350 -350s-350 157 -350 350c0 176 130 323 300 347v3
h-100v100zM700 584c0 0 -296 -348 -316 -368s-48 -20 -68 0s-20 48 0 68s384 300 384 300z" />
<glyph glyph-name="ce" unicode="&#xe0ce;"
d="M600 700l200 -150l-200 -150v100h-600v100h600v100zM200 300v-100h600v-100h-600v-100l-200 150z" />
<glyph glyph-name="cf" unicode="&#xe0cf;"
d="M300 800h100c55 0 100 -45 100 -100h100c55 0 100 -45 100 -100h-700c0 55 45 100 100 100h100c0 55 45 100 100 100zM100 500h100v-350c0 -28 22 -50 50 -50s50 22 50 50v350h100v-350c0 -28 22 -50 50 -50s50 22 50 50v350h100v-481c0 -11 -8 -19 -19 -19h-462
c-11 0 -19 8 -19 19v481z" />
<glyph glyph-name="d0" unicode="&#xe0d0;"
d="M100 800h200v-400c0 -55 45 -100 100 -100s100 45 100 100v400h100v-400c0 -110 -90 -200 -200 -200h-50c-138 0 -250 90 -250 200v400zM0 100h700v-100h-700v100z" />
<glyph glyph-name="d1" unicode="&#xe0d1;"
d="M9 700h182c6 0 9 -3 9 -9v-482c0 -6 -3 -9 -9 -9h-182c-6 0 -9 3 -9 9v482c0 6 3 9 9 9zM609 700h182c6 0 9 -3 9 -9v-482c0 -6 -3 -9 -9 -9h-182c-6 0 -9 3 -9 9v482c0 6 3 9 9 9zM309 500h182c6 0 9 -3 9 -9v-282c0 -6 -3 -9 -9 -9h-182c-6 0 -9 3 -9 9v282
c0 6 3 9 9 9zM0 100h800v-100h-800v100z" />
<glyph glyph-name="d2" unicode="&#xe0d2;"
d="M10 700h181c6 0 9 -3 9 -9v-191h-200v191c0 6 4 9 10 9zM610 700h181c6 0 9 -3 9 -9v-191h-200v191c0 6 5 9 10 9zM310 600h181c6 0 9 -3 9 -9v-91h-200v91c0 6 4 9 10 9zM0 400h800v-100h-800v100zM0 200h200v-191c0 -6 -3 -9 -9 -9h-182c-6 0 -9 3 -9 9v191zM300 200
h200v-91c0 -6 -3 -9 -9 -9h-181c-6 0 -10 3 -10 9v91zM600 200h200v-191c0 -6 -3 -9 -9 -9h-181c-6 0 -10 3 -10 9v191z" />
<glyph glyph-name="d3" unicode="&#xe0d3;"
d="M0 700h800v-100h-800v100zM9 500h182c6 0 9 -3 9 -9v-482c0 -6 -3 -9 -9 -9h-182c-6 0 -9 3 -9 9v482c0 6 3 9 9 9zM309 500h182c6 0 9 -3 9 -9v-282c0 -6 -3 -9 -9 -9h-182c-6 0 -9 3 -9 9v282c0 6 3 9 9 9zM609 500h182c6 0 9 -3 9 -9v-482c0 -6 -3 -9 -9 -9h-182
c-6 0 -9 3 -9 9v482c0 6 3 9 9 9z" />
<glyph glyph-name="d4" unicode="&#xe0d4;"
d="M50 600h500c28 0 50 -22 50 -50v-150l100 100h100v-300h-100l-100 100v-150c0 -28 -22 -50 -50 -50h-500c-28 0 -50 22 -50 50v400c0 28 22 50 50 50z" />
<glyph glyph-name="d5" unicode="&#xe0d5;"
d="M334 800h66v-800h-66l-134 200h-200v400h200zM500 600v100c26 0 52 -4 75 -10c130 -33 225 -150 225 -290s-95 -258 -225 -291h-3c-23 -6 -47 -9 -72 -9v100c17 0 34 2 50 6c86 22 150 100 150 194s-64 172 -150 194c-16 4 -33 6 -50 6zM500 500l25 -3
c44 -11 75 -51 75 -97s-32 -86 -75 -97l-25 -3v200z" />
<glyph glyph-name="d6" unicode="&#xe0d6;" horiz-adv-x="600"
d="M334 800h66v-800h-66l-134 200h-200v400h200zM500 500l25 -3c44 -11 75 -51 75 -97s-32 -86 -75 -97l-25 -3v200z" />
<glyph glyph-name="d7" unicode="&#xe0d7;" horiz-adv-x="400"
d="M334 800h66v-800h-66l-134 200h-200v400h200z" />
<glyph glyph-name="d8" unicode="&#xe0d8;"
d="M309 800h82c6 0 10 -4 12 -9l294 -682l3 -19v-81c0 -6 -3 -9 -9 -9h-682c-6 0 -9 3 -9 9v81l3 19l294 682c2 5 6 9 12 9zM300 500v-200h100v200h-100zM300 200v-100h100v100h-100z" />
<glyph glyph-name="d9" unicode="&#xe0d9;"
d="M375 800c138 0 269 -39 378 -109l-53 -82c-93 60 -205 91 -325 91c-119 0 -229 -32 -322 -91l-53 82c109 70 237 109 375 109zM375 500c78 0 154 -23 216 -62l-53 -85c-46 30 -104 47 -163 47c-60 0 -112 -17 -159 -47l-54 85c62 40 134 62 213 62zM375 200
c55 0 100 -45 100 -100s-45 -100 -100 -100s-100 45 -100 100s45 100 100 100z" />
<glyph glyph-name="da" unicode="&#xe0da;" horiz-adv-x="900"
d="M551 800c16 0 32 0 47 -3l-97 -97v-200h200l97 97c3 -15 3 -31 3 -47c0 -138 -112 -250 -250 -250c-32 0 -62 8 -90 19l-288 -291c-20 -20 -46 -28 -72 -28s-52 8 -72 28c-39 39 -39 105 0 144l291 287c-11 28 -19 59 -19 91c0 138 112 250 250 250zM101 150
c-28 0 -50 -22 -50 -50s22 -50 50 -50s50 22 50 50s-22 50 -50 50z" />
<glyph glyph-name="db" unicode="&#xe0db;"
d="M141 700c84 -84 169 -167 253 -250c82 83 167 165 247 250l143 -141l-253 -253c84 -82 167 -166 253 -247l-143 -143c-81 86 -165 169 -247 253l-253 -253l-141 143c85 80 167 164 250 247c-83 84 -166 169 -250 253z" />
<glyph glyph-name="dc" unicode="&#xe0dc;"
d="M0 800h100l231 -300h38l231 300h100l-225 -300h225v-100h-300v-100h300v-100h-300v-200h-100v200h-300v100h300v100h-300v100h225z" />
<glyph glyph-name="dd" unicode="&#xe0dd;" horiz-adv-x="900"
d="M350 800c193 0 350 -157 350 -350c0 -61 -17 -119 -44 -169c4 -2 10 -6 13 -9l103 -100c16 -16 30 -49 30 -72c0 -56 -46 -102 -102 -102c-23 0 -56 14 -72 30l-100 103c-3 3 -7 9 -9 13c-50 -28 -108 -44 -169 -44c-193 0 -350 157 -350 350s157 350 350 350zM350 700
c-139 0 -250 -111 -250 -250s111 -250 250 -250c62 0 119 23 163 60c7 11 19 25 31 31l3 3c34 43 53 97 53 156c0 139 -111 250 -250 250zM300 600h100v-100h100v-100h-100v-100h-100v100h-100v100h100v100z" />
<glyph glyph-name="de" unicode="&#xe0de;" horiz-adv-x="900"
d="M350 800c193 0 350 -157 350 -350c0 -61 -17 -119 -44 -169c4 -2 10 -6 13 -9l103 -100c16 -16 30 -49 30 -72c0 -56 -46 -102 -102 -102c-23 0 -56 14 -72 30l-100 103c-3 3 -7 9 -9 13c-50 -28 -108 -44 -169 -44c-193 0 -350 157 -350 350s157 350 350 350zM350 700
c-139 0 -250 -111 -250 -250s111 -250 250 -250c62 0 119 23 163 60c7 11 19 25 31 31l3 3c34 43 53 97 53 156c0 139 -111 250 -250 250zM200 500h300v-100h-300v100z" />
</font>
</defs></svg>

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,50 @@
defmodule Frenzy.OPML.ExporterTest do
use ExUnit.Case
alias Frenzy.OPML.Exporter
alias Frenzy.{Feed, Group}
doctest Exporter
test "export groups" do
res =
Exporter.export([
%Group{
title: "Group 1",
feeds: [
%Feed{
feed_url: "https://shadowfacts.net/feed.xml",
site_url: "https://shadowfacts.net/",
title: "Shadowfacts"
}
]
},
%Group{
title: "Group 2",
feeds: [
%Feed{
feed_url: "some other url",
site_url: "my site",
title: "the title"
}
]
}
])
assert res ==
"""
<opml version="1.0">
<head>
<title>Frenzy export</title>
</head>
<body>
<outline text="Group 1" title="Group 1">
<outline htmlUrl="https://shadowfacts.net/" text="Shadowfacts" title="Shadowfacts" type="rss" xmlUrl="https://shadowfacts.net/feed.xml"/>
</outline>
<outline text="Group 2" title="Group 2">
<outline htmlUrl="my site" text="the title" title="the title" type="rss" xmlUrl="some other url"/>
</outline>
</body>
</opml>
"""
|> String.trim()
end
end

View File

@ -0,0 +1,30 @@
defmodule Frenzy.OPML.ImporterTests do
use ExUnit.Case
alias Frenzy.OPML.Importer
doctest Importer
@opml """
<?xml version="1.0" encoding="UTF-8"?>
<!-- OPML generated by NetNewsWire -->
<opml version="1.1">
<head>
<title>Subscriptions-OnMyMac.opml</title>
</head>
<body>
<outline text="Julia Evans" title="Julia Evans" description="" type="rss" version="RSS" htmlUrl="" xmlUrl="https://jvns.ca/atom.xml"/>
<outline text="my folder" title="my folder">
<outline text="The Shape of Everything" title="The Shape of Everything" description="" type="rss" version="RSS" htmlUrl="https://shapeof.com/" xmlUrl="https://shapeof.com/feed.json"/>
</outline>
</body>
</opml>
"""
test "parse simple OPML" do
res = Importer.parse_opml(@opml)
assert res == %{
:default => ["https://jvns.ca/atom.xml"],
"my folder" => ["https://shapeof.com/feed.json"]
}
end
end

View File

@ -18,7 +18,8 @@ defmodule FrenzyWeb.ConnCase do
using do
quote do
# Import conveniences for testing with connections
use Phoenix.ConnTest
import Plug.Conn
import Phoenix.ConnTest
alias FrenzyWeb.Router.Helpers, as: Routes
# The default endpoint for testing