Compare commits
7 Commits
Author | SHA1 | Date |
---|---|---|
Tim Van Baak | 40464e9078 | |
Tim Van Baak | 29740d5864 | |
Tim Van Baak | 926a67a05e | |
Tim Van Baak | 8828a4abef | |
Tim Van Baak | b7e83c5059 | |
Tim Van Baak | b480d6edfd | |
Tim Van Baak | 8fd6f3b751 |
|
@ -38,16 +38,16 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1685566663,
|
||||
"narHash": "sha256-btHN1czJ6rzteeCuE/PNrdssqYD2nIA4w48miQAFloM=",
|
||||
"lastModified": 1717179513,
|
||||
"narHash": "sha256-vboIEwIQojofItm2xGCdZCzW96U85l9nDW3ifMuAIdM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4ecab3273592f27479a583fb6d975d4aba3486fe",
|
||||
"rev": "63dacb46bf939521bdc93981b4cbb7ecb58427a0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "23.05",
|
||||
"ref": "24.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
|
|
12
flake.nix
12
flake.nix
|
@ -2,7 +2,7 @@
|
|||
description = "A personal feed aggregator";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/23.05";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/24.05";
|
||||
# Included to support default.nix and shell.nix
|
||||
flake-compat = {
|
||||
url = "github:edolstra/flake-compat";
|
||||
|
@ -43,16 +43,6 @@
|
|||
PS1="(develop) $PS1"
|
||||
'';
|
||||
};
|
||||
client = let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.nodejs_18
|
||||
];
|
||||
shellHook = ''
|
||||
PS1="(client) $PS1"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
overlays.default = final: prev: {
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
File diff suppressed because it is too large
Load Diff
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"name": "intake-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build"
|
||||
},
|
||||
"proxy": "http://localhost:5000",
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QAzAAAAADVpr6lAAAAOElEQVQ4y2NgoBAwMjAwMNQzMPwnR3MjAwMjC4zjS6LmzVCaBV2A7mDgw4CJUi8MvAGj0TgYwgAADKMLO3k0eaQAAAAASUVORK5CYII=">
|
||||
<style>
|
||||
main {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
article {
|
||||
border: 1px solid black; border-radius: 6px;
|
||||
padding: 5px;
|
||||
margin-bottom: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.item-title {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.item-button {
|
||||
font-size: 1em;
|
||||
float:right;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.item-link {
|
||||
text-decoration: none;
|
||||
float:right;
|
||||
font-size: 1em;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.item-info {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
article img {
|
||||
max-width: 100%;
|
||||
}
|
||||
button, summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
summary {
|
||||
display: block;
|
||||
}
|
||||
summary:focus {
|
||||
outline: 1px dotted gray;
|
||||
}
|
||||
.strikethru span, .strikethru p {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.fade span, .fade p {
|
||||
color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
table.feed-control td {
|
||||
font-family: monospace; padding: 5px 10px;
|
||||
}
|
||||
article.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input[type=checkbox]{
|
||||
height: 0;
|
||||
width: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
width: 3.8em;
|
||||
padding-inline: 1em 0;
|
||||
height: 1.2em;
|
||||
background: grey;
|
||||
display: inline-block;
|
||||
border-radius: 0.6em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
label:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.1em;
|
||||
left: 0.1em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background: #fff;
|
||||
border-radius: 0.5em;
|
||||
transition: 0.1s;
|
||||
}
|
||||
|
||||
input:checked + label {
|
||||
background: #cc0000;
|
||||
padding-inline: 0 1em;
|
||||
}
|
||||
|
||||
input:checked + label:after {
|
||||
left: calc(100% - 0.1em);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
label:active:after {
|
||||
width: 1.4em;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div id="root"></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,144 +0,0 @@
|
|||
import { StrictMode, useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
function Item({ item }) {
|
||||
|
||||
function deactivate(source, itemid) {
|
||||
console.log(`deactivate(${source}, ${itemid})`);
|
||||
}
|
||||
|
||||
function punt(source, itemid) {
|
||||
console.log(`punt(${source}, ${itemid})`);
|
||||
}
|
||||
|
||||
function doAction(source, itemid, action) {
|
||||
console.log(`doAction(${source}, ${itemid}, ${action})`);
|
||||
};
|
||||
|
||||
let classNames = !item.hidden ? "" : item.active ? "fade" : "strikethru fade";
|
||||
return (
|
||||
<article className={classNames} id={`${item.source}-${item.id}`}>
|
||||
<button
|
||||
className="item-button"
|
||||
title="Deactivate"
|
||||
onClick={() => deactivate(item.source, item.id)}>✕</button>
|
||||
<button
|
||||
className="item-button"
|
||||
title="Punt to tomorrow"
|
||||
onClick={() => punt(item.source, item.id)}>↷</button>
|
||||
{item.link && <a className="item-link" href="#" target="_blank">⇗</a>}
|
||||
{/* The item title is a clickable <details> if there is body content */}
|
||||
{(item.body || item.actions) ? (
|
||||
<details open>
|
||||
<summary><span className="item-title">{item.title || item.id}</span></summary>
|
||||
{item.body && (
|
||||
<p dangerouslySetInnerHTML={{ __html: item.body }}></p>
|
||||
)}
|
||||
{item.actions && item.actions.map((action) => {
|
||||
return (
|
||||
<p key={`${item.source}-${item.id}-action-${action}`}>
|
||||
<button onClick={() => doAction(item.source, item.id, action)}>
|
||||
{action}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</details>
|
||||
) : (
|
||||
<>
|
||||
<span className="item-title">{item.title || item.id}</span>
|
||||
<br/>
|
||||
</>
|
||||
)}
|
||||
{/* Author/time footer */}
|
||||
{(item.author || item.time) && (
|
||||
<>
|
||||
<span className="item-info">{item.author} {item.time}</span><br/>
|
||||
</>
|
||||
)}
|
||||
{/* Source/id/created footer */}
|
||||
{(item.source || item.id || item.created) && (
|
||||
<span className="item-info" title="Tags: TODO">
|
||||
{item.source} {item.id} {item.created}
|
||||
{item.ttl && "T"} {item.ttd && "D"} {item.tts && "S"}
|
||||
</span>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetch(`http://localhost:5000/api/v1/items?hidden=${showHidden}`)
|
||||
.then(response => response.json())
|
||||
.then(newItems => setItems(newItems.items))
|
||||
.catch(error => console.log(error));
|
||||
}, [showHidden]);
|
||||
|
||||
// Button actions
|
||||
function bulkDeactivate(items) {
|
||||
if (confirm(`Deactivate ${items.length} items?`)) {
|
||||
console.log(`bulkDeactivate(${items})`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<article className="center">
|
||||
<span className="item-title">
|
||||
<a href="{{url_for('root')}}">Home</a>
|
||||
<input
|
||||
id="showHidden"
|
||||
type="checkbox"
|
||||
checked={showHidden}
|
||||
onChange={(e) => setShowHidden(e.target.checked) }
|
||||
className="toggle-checkbox"/>
|
||||
<label htmlFor="showHidden">{showHidden ? "All" : "Active"}</label>
|
||||
{/* {% if item_count > items|length -%} */}
|
||||
{/* [<a {% if page_num is greaterthan(0) -%} href="{{ set_query(page=page_num - 1) }}" {%- endif %}>Prev</a> */}
|
||||
{/* | */}
|
||||
{/* <a {% if ((page_num + 1) * page_count) is lessthan(item_count) -%} href="{{ set_query(page=page_num + 1) }}" {%- endif %}>Next</a>] */}
|
||||
{/* {%- endif %} */}
|
||||
</span>
|
||||
</article>
|
||||
|
||||
|
||||
{items.map((item) => {
|
||||
return <Item item={item} key={item.id}/>;
|
||||
})}
|
||||
|
||||
{items.length == 0 && (
|
||||
<article className="center">
|
||||
<span className="item-title">Feed is empty</span>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* {% if item_count > items|length %} */}
|
||||
<article className="center">
|
||||
<span className="item-title">
|
||||
{/* <a {% if page_num is greaterthan(0) -%} href="{{ set_query(page=page_num - 1) }}" {%- endif %}>Prev</a> */}
|
||||
|
|
||||
{/* <a {% if ((page_num + 1) * page_count) is lessthan(item_count) -%} href="{{ set_query(page=page_num + 1) }}" {%- endif %}>Next</a> */}
|
||||
</span>
|
||||
</article>
|
||||
{/* {% endif %} */}
|
||||
|
||||
<article className="center">
|
||||
<button onClick={() => bulkDeactivate(items)}>Deactivate All</button>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
119
intake/app.py
119
intake/app.py
|
@ -253,7 +253,7 @@ def action(source_name, item_id, action):
|
|||
data_path: Path = current_app.config["INTAKE_DATA"]
|
||||
source = LocalSource(data_path, source_name)
|
||||
item = execute_action(source, item_id, action)
|
||||
return jsonify(item)
|
||||
return jsonify(item._item)
|
||||
|
||||
|
||||
@app.route("/edit/source/<string:name>", methods=["GET", "POST"])
|
||||
|
@ -415,120 +415,3 @@ def _get_ttx_for_date(dt: datetime) -> int:
|
|||
def wsgi():
|
||||
app.config["INTAKE_DATA"] = intake_data_dir()
|
||||
return app
|
||||
|
||||
|
||||
#
|
||||
# Experimental API endpoints for the React frontend
|
||||
#
|
||||
|
||||
|
||||
def to_api_model(item: Item) -> dict:
|
||||
"""
|
||||
Convert an item to a JSON representation for the frontend.
|
||||
"""
|
||||
result = {
|
||||
"source": item.source.source_name,
|
||||
"id": item["id"],
|
||||
"created": item["created"],
|
||||
"active": item["active"],
|
||||
}
|
||||
for unchanged_field in (
|
||||
"title",
|
||||
"author",
|
||||
"body",
|
||||
"link",
|
||||
"time",
|
||||
"tags",
|
||||
"tts",
|
||||
"ttl",
|
||||
"ttd",
|
||||
):
|
||||
if val := item.get(unchanged_field):
|
||||
result[unchanged_field] = val
|
||||
if actions := item.get("action"):
|
||||
result["actions"] = list(actions.keys())
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/items")
|
||||
# @auth_check
|
||||
def items():
|
||||
"""
|
||||
Get multiple items according to a filter.
|
||||
Supported filters:
|
||||
- &channel=<channel>
|
||||
- &source=<source>
|
||||
- &hidden=<true|false>
|
||||
- TODO &tags=<+tag,-tag,...>
|
||||
- &count= and &page=
|
||||
|
||||
Returns a JSON response with
|
||||
- count: number of items
|
||||
- items: items as JSON
|
||||
- prev: if there are previous pages, the previous page number
|
||||
- next: if there are further pages, the next page number
|
||||
"""
|
||||
data_path: Path = current_app.config["INTAKE_DATA"]
|
||||
|
||||
# &channels and &sources may not both be specified
|
||||
filter_channel = request.args.get("channel")
|
||||
filter_source = request.args.get("source")
|
||||
if filter_channel and filter_source:
|
||||
response = jsonify({"error": "One of channel and source may be specified"})
|
||||
response.status_code = 400
|
||||
return response
|
||||
|
||||
source_names = []
|
||||
|
||||
# If the channel was specified, load the channel defs to get the sources
|
||||
if filter_channel:
|
||||
channels_config_path = data_path / "channels.json"
|
||||
if not channels_config_path.exists():
|
||||
abort(404)
|
||||
channels = json.loads(channels_config_path.read_text(encoding="utf8"))
|
||||
if filter_channel not in channels:
|
||||
abort(404)
|
||||
source_names = channels[filter_channel]
|
||||
|
||||
# If a source was specified, use that source
|
||||
elif filter_source:
|
||||
source_names = [filter_source]
|
||||
|
||||
# If neither was specified, use all sources
|
||||
else:
|
||||
source_names = [
|
||||
child.name
|
||||
for child in data_path.iterdir()
|
||||
if (child / "intake.json").exists()
|
||||
]
|
||||
|
||||
sources = [LocalSource(data_path, name) for name in source_names]
|
||||
|
||||
# Get the items, applying the hidden filter
|
||||
show_hidden = request.args.get("hidden") == "true"
|
||||
all_items = sorted(
|
||||
[
|
||||
item
|
||||
for source in sources
|
||||
for item in source.get_all_items()
|
||||
if not item.is_hidden or show_hidden
|
||||
],
|
||||
key=item_sort_key,
|
||||
)
|
||||
|
||||
# Apply paging filters
|
||||
count = int(request.args.get("count", "100"))
|
||||
page = int(request.args.get("page", "0"))
|
||||
paged_items = all_items[count * page : count * page + count]
|
||||
|
||||
# Return the result set
|
||||
response_params = {
|
||||
"count": len(paged_items),
|
||||
"items": list(map(to_api_model, paged_items)),
|
||||
}
|
||||
if page > 0:
|
||||
response_params["prev"] = page - 1
|
||||
if (count * page + count) < len(all_items):
|
||||
response_params["next"] = page + 1
|
||||
response = jsonify(response_params)
|
||||
return response
|
||||
|
|
|
@ -172,7 +172,7 @@ def cmd_action(cmd_args):
|
|||
source = LocalSource(data_path, args.source)
|
||||
try:
|
||||
item = execute_action(source, args.item, args.action, 5)
|
||||
print("Item:", item, file=sys.stderr)
|
||||
print("Item:", item._item, file=sys.stderr)
|
||||
except InvalidConfigException as ex:
|
||||
print("Could not fetch", args.source, file=sys.stderr)
|
||||
print(ex, file=sys.stderr)
|
||||
|
|
|
@ -53,6 +53,7 @@ def update_crontab_entries(data_path: Path):
|
|||
section_found = False
|
||||
in_section = False
|
||||
for i in range(len(crontab_lines)):
|
||||
|
||||
if not section_found and crontab_lines[i] == INTAKE_CRON_BEGIN:
|
||||
section_found = True
|
||||
in_section = True
|
||||
|
|
|
@ -35,6 +35,7 @@ article {
|
|||
}
|
||||
article img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
article textarea {
|
||||
width: 100%;
|
||||
|
|
|
@ -35,6 +35,7 @@ article {
|
|||
}
|
||||
article img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, summary {
|
||||
cursor: pointer;
|
||||
|
|
|
@ -19,6 +19,7 @@ article {
|
|||
}
|
||||
article img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, summary {
|
||||
cursor: pointer;
|
||||
|
@ -53,7 +54,7 @@ summary:focus {
|
|||
</article>
|
||||
|
||||
<article>
|
||||
<details open>
|
||||
<details>
|
||||
<summary><span class="item-title">Sources</span></summary>
|
||||
{% if not sources %}
|
||||
<p>No sources found.</p>
|
||||
|
|
|
@ -57,7 +57,7 @@ in {
|
|||
let
|
||||
# Define the intake package and a python environment to run it from
|
||||
intake = intakeCfg.package;
|
||||
pythonEnv = pkgs.python38.withPackages (pypkgs: [ intake ]);
|
||||
pythonEnv = pkgs.python3.withPackages (pypkgs: [ intake ]);
|
||||
|
||||
# Assign each user an internal port for their personal intake instance
|
||||
enabledUsers = filterAttrs (userName: userCfg: userCfg.enable) intakeCfg.users;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "intake"
|
||||
version = "1.0.4"
|
||||
version = "1.1.0"
|
||||
|
||||
[project.scripts]
|
||||
intake = "intake.cli:main"
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import argparse, json, sys, time
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("args:", args, file=sys.stderr, flush=True)
|
||||
|
||||
def item(i):
|
||||
print(json.dumps(i))
|
||||
|
||||
if args.action == "fetch":
|
||||
item({
|
||||
"id": "item1-only-id",
|
||||
})
|
||||
item({
|
||||
"id": "item2-title",
|
||||
"title": "This item has a title",
|
||||
})
|
||||
item({
|
||||
"id": "item3-body",
|
||||
"title": "This item has a title and body",
|
||||
"body": "<p>Hello, intake! This is an <b>item</b> body.</p>",
|
||||
})
|
||||
item({
|
||||
"id": "item4-action",
|
||||
"title": "This item has an action but no body",
|
||||
"action": {
|
||||
"action1": None
|
||||
},
|
||||
})
|
||||
item({
|
||||
"id": "item5-actions",
|
||||
"title": "This item has two actions and a body",
|
||||
"body": "<p>This is body text.</p>",
|
||||
"action": {
|
||||
"action1": None,
|
||||
"action2": None,
|
||||
},
|
||||
})
|
||||
item({
|
||||
"id": "item6-footer",
|
||||
"title": "No body text but all footer fields",
|
||||
"author": "Authorname",
|
||||
"time": int(time.time()),
|
||||
})
|
||||
item({
|
||||
"id": "item7-footer",
|
||||
"title": "Body text and all footer fields",
|
||||
"author": "Authorname",
|
||||
"time": int(time.time()),
|
||||
"body": "<p>This is body text.</p>",
|
||||
})
|
||||
item({
|
||||
"id": "item8-link",
|
||||
"title": "Item with a link",
|
||||
"link": "#",
|
||||
})
|
||||
|
||||
if args.action == "action1":
|
||||
item = sys.stdin.readline()
|
||||
item = json.loads(item)
|
||||
print(json.dumps(item))
|
||||
|
||||
if args.action == "action2":
|
||||
item = sys.stdin.readline()
|
||||
item = json.loads(item)
|
||||
print(json.dumps(item))
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"action": {
|
||||
"fetch": {
|
||||
"exe": "./command.py",
|
||||
"args": [
|
||||
"fetch"
|
||||
]
|
||||
},
|
||||
"action1": {
|
||||
"exe": "./command.py",
|
||||
"args": [
|
||||
"action1"
|
||||
]
|
||||
},
|
||||
"action2": {
|
||||
"exe": "./command.py",
|
||||
"args": [
|
||||
"action2"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue