Compare commits

..

7 Commits

18 changed files with 14 additions and 17246 deletions

View File

@ -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"
}

View File

@ -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: {

View File

@ -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

View File

@ -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"
]
}
}

View File

@ -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>

View File

@ -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)}>&#10005;</button>
<button
className="item-button"
title="Punt to tomorrow"
onClick={() => punt(item.source, item.id)}>&#8631;</button>
{item.link && <a className="item-link" href="#" target="_blank">&#8663;</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>
);

View File

@ -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

View File

@ -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)

View File

@ -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

0
intake/source.py Executable file → Normal file
View File

View File

@ -35,6 +35,7 @@ article {
}
article img {
max-width: 100%;
height: auto;
}
article textarea {
width: 100%;

View File

@ -35,6 +35,7 @@ article {
}
article img {
max-width: 100%;
height: auto;
}
button, summary {
cursor: pointer;

View File

@ -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>

View File

@ -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;

View File

@ -1,6 +1,6 @@
[project]
name = "intake"
version = "1.0.4"
version = "1.1.0"
[project.scripts]
intake = "intake.cli:main"

View File

@ -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))

View File

@ -1,22 +0,0 @@
{
"action": {
"fetch": {
"exe": "./command.py",
"args": [
"fetch"
]
},
"action1": {
"exe": "./command.py",
"args": [
"action1"
]
},
"action2": {
"exe": "./command.py",
"args": [
"action2"
]
}
}
}