py/icalfilter: init

This commit is contained in:
Luke Granger-Brown 2022-05-16 01:48:48 +01:00
parent 059476d789
commit 82d96db444
16 changed files with 7959 additions and 0 deletions

View file

@ -4,4 +4,5 @@
args: { args: {
valveindexinstock = import ./valveindexinstock args; valveindexinstock = import ./valveindexinstock args;
icalfilter = import ./icalfilter args;
} }

View file

273
py/icalfilter/app.py Normal file
View file

@ -0,0 +1,273 @@
import asyncio
import concurrent.futures
import dataclasses
import datetime
import json
from typing import Any, Dict, List, Optional, Set, Union
import os
import aiohttp
import attrs
import icalendar
import icalevents.icalparser
from dateutil.tz import UTC
from quart import Quart, Response, render_template
async def parse_ical(calendar_text: str) -> icalendar.Calendar:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
process_pool, icalendar.Calendar.from_ical, calendar_text
)
async def serialize_ical(calendar: icalendar.Calendar) -> str:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(process_pool, calendar.to_ical)
def maybe_fromisoformat(val: Optional[str]) -> Optional[datetime.datetime]:
if not val:
return None
if isinstance(val, datetime.datetime):
return val
return datetime.datetime.fromisoformat(val)
@attrs.frozen
class Calendar:
name: str
source_url: str
keep_hashtags: Set[str] = attrs.field(converter=frozenset, factory=frozenset)
drop_hashtags: Set[str] = attrs.field(converter=frozenset, factory=frozenset)
minimize: bool = True
skip_minimize_hashtags: Set[str] = attrs.field(
converter=frozenset, factory=frozenset
)
skip_before: Optional[datetime.datetime] = attrs.field(
converter=maybe_fromisoformat, default=None
)
skip_if_declined: Set[str] = attrs.field(converter=frozenset, factory=frozenset)
retitle_to: Optional[str] = None
async def fetch_events(self, session: aiohttp.ClientSession) -> icalendar.Calendar:
async with session.get(self.source_url) as response:
response.raise_for_status()
text = await response.text()
return await parse_ical(text)
def make_calendars(
in_calendars: Union[List[Dict[str, Any]], List[Calendar]]
) -> List[Calendar]:
if len(in_calendars) == 0:
return []
elif isinstance(in_calendars[0], Calendar):
return in_calendars
return [Calendar(**c) for c in in_calendars]
@attrs.frozen
class Config:
calendars: List[Calendar] = attrs.field(converter=make_calendars)
async def fetch_calendars(
self, session: aiohttp.ClientSession
) -> List[icalendar.Calendar]:
return await asyncio.gather(*[c.fetch_events(session) for c in self.calendars])
def load_config(fn: str) -> Config:
with open(fn, "rt") as f:
return Config(**json.load(f))
app = Quart(__name__)
config = load_config(os.environ.get("ICALFILTER_CONFIG", "config/config.json"))
process_pool = concurrent.futures.ProcessPoolExecutor(max_workers=4)
def contains_any_hashtag(text: str, hashtags: Set[str]) -> bool:
return any(hashtag in text for hashtag in hashtags)
def _all_occurrences_before_expensive(
event: icalendar.Event,
cutoff: datetime.datetime,
seen_timezones: Dict[str, icalendar.Timezone],
) -> bool:
# Recurring events are... more complicated.
# Generate an iCal of just this event...
fake_cal = icalendar.Calendar()
for tz in seen_timezones.values():
fake_cal.add_component(tz)
fake_cal.add_component(event)
# and check to see if we get any instances that are after the cutoff.
instances = icalevents.icalparser.parse_events(fake_cal.to_ical(), start=cutoff)
return not instances
async def all_occurrences_before(
event: icalendar.Event,
cutoff: datetime.datetime,
seen_timezones: Dict[str, icalendar.Timezone],
) -> bool:
parsed_event = icalevents.icalparser.create_event(event, cutoff.tzinfo)
if not parsed_event.recurring:
return parsed_event.end < cutoff
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
process_pool, _all_occurrences_before_expensive, event, cutoff, seen_timezones
)
async def keep_event(
source_cal: Calendar,
event: icalendar.Event,
seen_timezones: Dict[str, icalendar.Timezone],
) -> bool:
if source_cal.keep_hashtags:
if not contains_any_hashtag(event["summary"], source_cal.keep_hashtags):
return False
if source_cal.drop_hashtags:
if contains_any_hashtag(event["summary"], source_cal.drop_hashtags):
return False
if source_cal.skip_before:
if "dtstart" not in event:
print("no dtstart?", event)
return False
if await all_occurrences_before(event, source_cal.skip_before, seen_timezones):
return False
if source_cal.skip_if_declined:
attendees = event.get("attendee", [])
if not isinstance(attendees, list):
# Sometimes it's just a plain vCalAddress
attendees = [attendees]
for attendee in attendees:
if str(attendee) not in source_cal.skip_if_declined:
continue
if attendee.params.get("PARTSTAT", None) == "DECLINED":
return False
return True
def maybe_strip_event(source_cal: Calendar, event: icalendar.Event) -> icalendar.Event:
if not source_cal.minimize:
return event
if source_cal.retitle_to:
event["summary"] = source_cal.retitle_to
out_event = icalendar.Event()
for prop in [
"uid",
"dtstamp",
"summary",
"dtstart",
"dtend",
"duration",
"recurrence-id",
"sequence",
"rrule",
"rdate",
"exdate",
]:
if prop in event:
out_event[prop] = event[prop]
return out_event
class Cache:
def __init__(self, timeout, cb):
self._timeout = timeout
self._cb = cb
self._cond = asyncio.Condition()
self._value = None
self._value_ttl = None
self._acquiring_value = False
def _check_value(self):
now = datetime.datetime.utcnow()
if self._value_ttl and now >= self._value_ttl:
self._value = None
self._value_ttl = None
if not self._value_ttl:
return (None, False)
return (self._value, True)
async def get_value(self):
await self._cond.acquire()
while True:
if self._acquiring_value:
await self._cond.wait()
value, ok = self._check_value()
if ok:
self._cond.release()
return value
self._acquiring_value = True
self._cond.release()
try:
value = await self._cb()
except:
await self._cond.acquire()
self._acquiring_value = False
self._cond.notify_all()
self._cond.release()
raise
await self._cond.acquire()
self._value = value
self._value_ttl = datetime.datetime.utcnow() + self._timeout
self._acquiring_value = False
self._cond.notify_all()
self._cond.release()
return value
async def render_ical():
async with aiohttp.ClientSession() as session:
icals = await config.fetch_calendars(session)
cal = icalendar.Calendar()
cal.add("prodid", "-//icalfilter//lukegb.com//")
cal.add("version", "2.0")
seen_timezones = {}
for source_cal, source_ical in zip(config.calendars, icals):
for event in source_ical.subcomponents:
if isinstance(event, icalendar.Timezone):
if event["tzid"] in seen_timezones:
continue
seen_timezones[event["tzid"]] = event
cal.add_component(event)
for source_cal, source_ical in zip(config.calendars, icals):
for event in source_ical.subcomponents:
if not isinstance(event, icalendar.Event):
continue
if not await keep_event(source_cal, event, seen_timezones):
continue
cal.add_component(maybe_strip_event(source_cal, event))
return await serialize_ical(cal)
ical_cache = Cache(datetime.timedelta(hours=3), render_ical)
@app.get("/ical.ics")
async def render_ical_view():
cal_text = await ical_cache.get_value()
return Response(cal_text, mimetype="text/calendar")
@app.get("/")
async def index():
return await render_template("index.html")
if __name__ == "__main__":
import sys
print('This is intended to be run with "quart run".', file=sys.stderr)
sys.exit(1)

View file

@ -0,0 +1,28 @@
{
"calendars": [
{
"name": "lukegb@lukegb.com",
"source_url": "https://calendar.google.com/calendar/ical/lukegb%40lukegb.com/private-6995ac8a6958dc78fbd11c78fd8ea319/basic.ics",
"keep_hashtags": [
"#public",
"nuric:lukegb memes"
],
"skip_before": "2022-05-01T00:00:00+00:00",
"skip_if_declined": [
"mailto:lukegb@lukegb.com"
]
},
{
"name": "Flat shared",
"source_url": "https://calendar.google.com/calendar/ical/c_d03usk18l2h5oc6s7sjdjgjsjg%40group.calendar.google.com/private-4aee6b35a0491104b62690a99b0d0769/basic.ics",
"keep_hashtags": [
"#public"
]
},
{
"name": "lukegb@ oncall",
"source_url": "https://calendar.google.com/calendar/ical/google.com_b2qlicba6qr0t9n20eomp7qvqo%40group.calendar.google.com/public/basic.ics",
"retitle_to": "lukegb oncall"
}
]
}

167
py/icalfilter/default.nix Normal file
View file

@ -0,0 +1,167 @@
# SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
#
# SPDX-License-Identifier: Apache-2.0
{ depot, lib, pkgs, ... }@args:
let
DateTime = ps: ps.buildPythonPackage rec {
pname = "DateTime";
version = "4.4";
propagatedBuildInputs = with ps; [
zope_interface
pytz
];
src = pkgs.fetchFromGitHub {
owner = "zopefoundation";
repo = "DateTime";
rev = version;
hash = "sha256:04p8sl4djygismc4mxgh4llgvw91b3a0hpal7rcc2hxl4hwasa3r";
};
};
icalevents = ps: ps.buildPythonPackage rec {
pname = "icalevents";
version = "0.1.26";
format = "pyproject";
prePatch = ''
substituteInPlace pyproject.toml \
--replace 'pytz = "==2021.3"' 'pytz = "*"' \
--replace '= "==' '= "^'
'';
nativeBuildInputs = with ps; [ poetry-core ];
propagatedBuildInputs = with ps; [
httplib2
icalendar
python-dateutil
pytz
(DateTime ps)
];
src = pkgs.fetchFromGitHub {
owner = "jazzband";
repo = pname;
rev = "v${version}";
hash = "sha256:06mq3nzn7vipmb1jvw3c05cw3k3bgvkgs02xqzry94pjvbn0nmiz";
};
};
quart = ps: ps.buildPythonPackage rec {
pname = "quart";
version = "0.17.0";
format = "pyproject";
nativeBuildInputs = with ps; [ poetry-core ];
propagatedBuildInputs = with ps; [
aiofiles
blinker
click
hypercorn
itsdangerous
jinja2
markupsafe
toml
werkzeug
];
src = pkgs.fetchFromGitLab {
owner = "pgjones";
repo = "quart";
rev = version;
hash = "sha256:19f11i2lvbsfxk1hhbm6xwmxw2avwb6jx9z2nyi9njk8w442z4y6";
};
};
python = pkgs.python3.withPackages (ps: with ps; [
attrs
(quart ps)
aiohttp
icalendar
gunicorn
uvicorn
(icalevents ps)
]);
filterSourcePred = (path: type: (type == "regular" &&
lib.hasSuffix ".py" path ||
lib.hasSuffix ".html" path
) || (
type == "directory" &&
baseNameOf path != "__pycache__" &&
baseNameOf path != "node_modules" &&
baseNameOf path != "config" &&
baseNameOf path != "web" &&
true));
web = import ./web args;
icalfilter = pkgs.stdenvNoCC.mkDerivation rec {
name = "icalfilter";
src = builtins.filterSource filterSourcePred ./.;
inherit web;
buildInputs = with pkgs; [ makeWrapper ];
propagatedBuildInputs = [ python ];
installPhase = ''
sitepkgdir="$out/lib/${python.libPrefix}/site-packages"
pkgdir="$sitepkgdir/icalfilter"
mkdir -p $pkgdir
cp -R \
*.py \
$pkgdir
cp -R $src/templates $pkgdir/templates
cp -R $web $pkgdir/static
mkdir "$out/bin"
makeWrapper "${python}/bin/gunicorn" "$out/bin/icalfilter" \
--add-flags "-w" \
--add-flags "4" \
--add-flags "-k" \
--add-flags "uvicorn.workers.UvicornWorker" \
--add-flags "icalfilter.app:app" \
--suffix PYTHONPATH : "$sitepkgdir"
'';
passthru.pythonEnv = python;
};
in
icalfilter // rec {
gcloudRegion = "europe-west1";
gcloudProject = "icalfilter-350303";
imageName = "${gcloudRegion}-docker.pkg.dev/${gcloudProject}/icalfilter/icalfilter";
dockerImage = pkgs.dockerTools.buildImage {
name = imageName;
config = {
Entrypoint = [ "${icalfilter}/bin/icalfilter" ];
Env = [
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
];
};
};
uploadCmd = pkgs.writeShellApplication {
name = "upload-icalfilter";
runtimeInputs = with pkgs; [ skopeo google-cloud-sdk ];
text = ''
echo
echo Uploading ${imageName}
skopeo copy docker-archive:${dockerImage} docker://${imageName}:v1
echo
echo Switching Cloud Run over
gcloud --project ${gcloudProject} run deploy icalfilter --region ${gcloudRegion} --image ${imageName}:v1
'';
};
}

13
py/icalfilter/shell.nix Normal file
View file

@ -0,0 +1,13 @@
{ depot ? import <depot> {} }:
let
inherit (depot.py) icalfilter;
inherit (depot) pkgs;
in pkgs.mkShell {
buildInputs = with pkgs; [
icalfilter.pythonEnv
black
python3.pkgs.isort
];
}

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Calendar</title>
<link rel="stylesheet" href="/static/main.css">
<script src="/static/main.js"></script>
</head>
<body>
<div id="calendar"></div>
</body>
</html>

View file

@ -0,0 +1,31 @@
{ pkgs, ... }:
let
src = pkgs.nix-gitignore.gitignoreSourcePure ''
*
!main.js
!webpack.config.js
!package.json
!package-lock.json
'' ./.;
nodeComposition = import ./node-composition.nix { inherit pkgs; };
in
pkgs.runCommand "icalfilter-webui" {
inherit src;
nativeBuildInputs = [ nodeComposition.nodeDependencies ];
nodeDependencies = nodeComposition.nodeDependencies;
} ''
export PATH="$nodeDependencies/bin:$PATH"
cp -r $src $NIX_BUILD_TOP/src
chmod -R +w $NIX_BUILD_TOP/src
mkdir $out
cd $NIX_BUILD_TOP/src
ln -s $nodeDependencies/lib/node_modules ./node_modules
webpack
cp dist/* $out
''

21
py/icalfilter/web/main.js Normal file
View file

@ -0,0 +1,21 @@
import { Calendar } from '@fullcalendar/core'
import interactionPlugin from '@fullcalendar/interaction'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import icalendarPlugin from '@fullcalendar/icalendar'
import enGbLocale from '@fullcalendar/core/locales/en-gb'
document.addEventListener('DOMContentLoaded', () => {
const calendarEl = document.querySelector('#calendar')
const calendar = new Calendar(calendarEl, {
locale: enGbLocale,
plugins: [ interactionPlugin, dayGridPlugin, timeGridPlugin, listPlugin, icalendarPlugin ],
events: {
url: '/ical.ics',
format: 'ics',
},
});
calendar.render();
})

View file

@ -0,0 +1,17 @@
# This file has been generated by node2nix 1.11.1. Do not edit!
{pkgs ? import <nixpkgs> {
inherit system;
}, system ? builtins.currentSystem, nodejs ? pkgs."nodejs-16_x"}:
let
nodeEnv = import ./node-env.nix {
inherit (pkgs) stdenv lib python2 runCommand writeTextFile writeShellScript;
inherit pkgs nodejs;
libtool = if pkgs.stdenv.isDarwin then pkgs.darwin.cctools else null;
};
in
import ./node-packages.nix {
inherit (pkgs) fetchurl nix-gitignore stdenv lib fetchgit;
inherit nodeEnv;
}

View file

@ -0,0 +1,598 @@
# This file originates from node2nix
{lib, stdenv, nodejs, python2, pkgs, libtool, runCommand, writeTextFile, writeShellScript}:
let
# Workaround to cope with utillinux in Nixpkgs 20.09 and util-linux in Nixpkgs master
utillinux = if pkgs ? utillinux then pkgs.utillinux else pkgs.util-linux;
python = if nodejs ? python then nodejs.python else python2;
# Create a tar wrapper that filters all the 'Ignoring unknown extended header keyword' noise
tarWrapper = runCommand "tarWrapper" {} ''
mkdir -p $out/bin
cat > $out/bin/tar <<EOF
#! ${stdenv.shell} -e
$(type -p tar) "\$@" --warning=no-unknown-keyword --delay-directory-restore
EOF
chmod +x $out/bin/tar
'';
# Function that generates a TGZ file from a NPM project
buildNodeSourceDist =
{ name, version, src, ... }:
stdenv.mkDerivation {
name = "node-tarball-${name}-${version}";
inherit src;
buildInputs = [ nodejs ];
buildPhase = ''
export HOME=$TMPDIR
tgzFile=$(npm pack | tail -n 1) # Hooks to the pack command will add output (https://docs.npmjs.com/misc/scripts)
'';
installPhase = ''
mkdir -p $out/tarballs
mv $tgzFile $out/tarballs
mkdir -p $out/nix-support
echo "file source-dist $out/tarballs/$tgzFile" >> $out/nix-support/hydra-build-products
'';
};
# Common shell logic
installPackage = writeShellScript "install-package" ''
installPackage() {
local packageName=$1 src=$2
local strippedName
local DIR=$PWD
cd $TMPDIR
unpackFile $src
# Make the base dir in which the target dependency resides first
mkdir -p "$(dirname "$DIR/$packageName")"
if [ -f "$src" ]
then
# Figure out what directory has been unpacked
packageDir="$(find . -maxdepth 1 -type d | tail -1)"
# Restore write permissions to make building work
find "$packageDir" -type d -exec chmod u+x {} \;
chmod -R u+w "$packageDir"
# Move the extracted tarball into the output folder
mv "$packageDir" "$DIR/$packageName"
elif [ -d "$src" ]
then
# Get a stripped name (without hash) of the source directory.
# On old nixpkgs it's already set internally.
if [ -z "$strippedName" ]
then
strippedName="$(stripHash $src)"
fi
# Restore write permissions to make building work
chmod -R u+w "$strippedName"
# Move the extracted directory into the output folder
mv "$strippedName" "$DIR/$packageName"
fi
# Change to the package directory to install dependencies
cd "$DIR/$packageName"
}
'';
# Bundle the dependencies of the package
#
# Only include dependencies if they don't exist. They may also be bundled in the package.
includeDependencies = {dependencies}:
lib.optionalString (dependencies != []) (
''
mkdir -p node_modules
cd node_modules
''
+ (lib.concatMapStrings (dependency:
''
if [ ! -e "${dependency.packageName}" ]; then
${composePackage dependency}
fi
''
) dependencies)
+ ''
cd ..
''
);
# Recursively composes the dependencies of a package
composePackage = { name, packageName, src, dependencies ? [], ... }@args:
builtins.addErrorContext "while evaluating node package '${packageName}'" ''
installPackage "${packageName}" "${src}"
${includeDependencies { inherit dependencies; }}
cd ..
${lib.optionalString (builtins.substring 0 1 packageName == "@") "cd .."}
'';
pinpointDependencies = {dependencies, production}:
let
pinpointDependenciesFromPackageJSON = writeTextFile {
name = "pinpointDependencies.js";
text = ''
var fs = require('fs');
var path = require('path');
function resolveDependencyVersion(location, name) {
if(location == process.env['NIX_STORE']) {
return null;
} else {
var dependencyPackageJSON = path.join(location, "node_modules", name, "package.json");
if(fs.existsSync(dependencyPackageJSON)) {
var dependencyPackageObj = JSON.parse(fs.readFileSync(dependencyPackageJSON));
if(dependencyPackageObj.name == name) {
return dependencyPackageObj.version;
}
} else {
return resolveDependencyVersion(path.resolve(location, ".."), name);
}
}
}
function replaceDependencies(dependencies) {
if(typeof dependencies == "object" && dependencies !== null) {
for(var dependency in dependencies) {
var resolvedVersion = resolveDependencyVersion(process.cwd(), dependency);
if(resolvedVersion === null) {
process.stderr.write("WARNING: cannot pinpoint dependency: "+dependency+", context: "+process.cwd()+"\n");
} else {
dependencies[dependency] = resolvedVersion;
}
}
}
}
/* Read the package.json configuration */
var packageObj = JSON.parse(fs.readFileSync('./package.json'));
/* Pinpoint all dependencies */
replaceDependencies(packageObj.dependencies);
if(process.argv[2] == "development") {
replaceDependencies(packageObj.devDependencies);
}
replaceDependencies(packageObj.optionalDependencies);
/* Write the fixed package.json file */
fs.writeFileSync("package.json", JSON.stringify(packageObj, null, 2));
'';
};
in
''
node ${pinpointDependenciesFromPackageJSON} ${if production then "production" else "development"}
${lib.optionalString (dependencies != [])
''
if [ -d node_modules ]
then
cd node_modules
${lib.concatMapStrings (dependency: pinpointDependenciesOfPackage dependency) dependencies}
cd ..
fi
''}
'';
# Recursively traverses all dependencies of a package and pinpoints all
# dependencies in the package.json file to the versions that are actually
# being used.
pinpointDependenciesOfPackage = { packageName, dependencies ? [], production ? true, ... }@args:
''
if [ -d "${packageName}" ]
then
cd "${packageName}"
${pinpointDependencies { inherit dependencies production; }}
cd ..
${lib.optionalString (builtins.substring 0 1 packageName == "@") "cd .."}
fi
'';
# Extract the Node.js source code which is used to compile packages with
# native bindings
nodeSources = runCommand "node-sources" {} ''
tar --no-same-owner --no-same-permissions -xf ${nodejs.src}
mv node-* $out
'';
# Script that adds _integrity fields to all package.json files to prevent NPM from consulting the cache (that is empty)
addIntegrityFieldsScript = writeTextFile {
name = "addintegrityfields.js";
text = ''
var fs = require('fs');
var path = require('path');
function augmentDependencies(baseDir, dependencies) {
for(var dependencyName in dependencies) {
var dependency = dependencies[dependencyName];
// Open package.json and augment metadata fields
var packageJSONDir = path.join(baseDir, "node_modules", dependencyName);
var packageJSONPath = path.join(packageJSONDir, "package.json");
if(fs.existsSync(packageJSONPath)) { // Only augment packages that exist. Sometimes we may have production installs in which development dependencies can be ignored
console.log("Adding metadata fields to: "+packageJSONPath);
var packageObj = JSON.parse(fs.readFileSync(packageJSONPath));
if(dependency.integrity) {
packageObj["_integrity"] = dependency.integrity;
} else {
packageObj["_integrity"] = "sha1-000000000000000000000000000="; // When no _integrity string has been provided (e.g. by Git dependencies), add a dummy one. It does not seem to harm and it bypasses downloads.
}
if(dependency.resolved) {
packageObj["_resolved"] = dependency.resolved; // Adopt the resolved property if one has been provided
} else {
packageObj["_resolved"] = dependency.version; // Set the resolved version to the version identifier. This prevents NPM from cloning Git repositories.
}
if(dependency.from !== undefined) { // Adopt from property if one has been provided
packageObj["_from"] = dependency.from;
}
fs.writeFileSync(packageJSONPath, JSON.stringify(packageObj, null, 2));
}
// Augment transitive dependencies
if(dependency.dependencies !== undefined) {
augmentDependencies(packageJSONDir, dependency.dependencies);
}
}
}
if(fs.existsSync("./package-lock.json")) {
var packageLock = JSON.parse(fs.readFileSync("./package-lock.json"));
if(![1, 2].includes(packageLock.lockfileVersion)) {
process.stderr.write("Sorry, I only understand lock file versions 1 and 2!\n");
process.exit(1);
}
if(packageLock.dependencies !== undefined) {
augmentDependencies(".", packageLock.dependencies);
}
}
'';
};
# Reconstructs a package-lock file from the node_modules/ folder structure and package.json files with dummy sha1 hashes
reconstructPackageLock = writeTextFile {
name = "addintegrityfields.js";
text = ''
var fs = require('fs');
var path = require('path');
var packageObj = JSON.parse(fs.readFileSync("package.json"));
var lockObj = {
name: packageObj.name,
version: packageObj.version,
lockfileVersion: 1,
requires: true,
dependencies: {}
};
function augmentPackageJSON(filePath, dependencies) {
var packageJSON = path.join(filePath, "package.json");
if(fs.existsSync(packageJSON)) {
var packageObj = JSON.parse(fs.readFileSync(packageJSON));
dependencies[packageObj.name] = {
version: packageObj.version,
integrity: "sha1-000000000000000000000000000=",
dependencies: {}
};
processDependencies(path.join(filePath, "node_modules"), dependencies[packageObj.name].dependencies);
}
}
function processDependencies(dir, dependencies) {
if(fs.existsSync(dir)) {
var files = fs.readdirSync(dir);
files.forEach(function(entry) {
var filePath = path.join(dir, entry);
var stats = fs.statSync(filePath);
if(stats.isDirectory()) {
if(entry.substr(0, 1) == "@") {
// When we encounter a namespace folder, augment all packages belonging to the scope
var pkgFiles = fs.readdirSync(filePath);
pkgFiles.forEach(function(entry) {
if(stats.isDirectory()) {
var pkgFilePath = path.join(filePath, entry);
augmentPackageJSON(pkgFilePath, dependencies);
}
});
} else {
augmentPackageJSON(filePath, dependencies);
}
}
});
}
}
processDependencies("node_modules", lockObj.dependencies);
fs.writeFileSync("package-lock.json", JSON.stringify(lockObj, null, 2));
'';
};
prepareAndInvokeNPM = {packageName, bypassCache, reconstructLock, npmFlags, production}:
let
forceOfflineFlag = if bypassCache then "--offline" else "--registry http://www.example.com";
in
''
# Pinpoint the versions of all dependencies to the ones that are actually being used
echo "pinpointing versions of dependencies..."
source $pinpointDependenciesScriptPath
# Patch the shebangs of the bundled modules to prevent them from
# calling executables outside the Nix store as much as possible
patchShebangs .
# Deploy the Node.js package by running npm install. Since the
# dependencies have been provided already by ourselves, it should not
# attempt to install them again, which is good, because we want to make
# it Nix's responsibility. If it needs to install any dependencies
# anyway (e.g. because the dependency parameters are
# incomplete/incorrect), it fails.
#
# The other responsibilities of NPM are kept -- version checks, build
# steps, postprocessing etc.
export HOME=$TMPDIR
cd "${packageName}"
runHook preRebuild
${lib.optionalString bypassCache ''
${lib.optionalString reconstructLock ''
if [ -f package-lock.json ]
then
echo "WARNING: Reconstruct lock option enabled, but a lock file already exists!"
echo "This will most likely result in version mismatches! We will remove the lock file and regenerate it!"
rm package-lock.json
else
echo "No package-lock.json file found, reconstructing..."
fi
node ${reconstructPackageLock}
''}
node ${addIntegrityFieldsScript}
''}
npm ${forceOfflineFlag} --nodedir=${nodeSources} ${npmFlags} ${lib.optionalString production "--production"} rebuild
if [ "''${dontNpmInstall-}" != "1" ]
then
# NPM tries to download packages even when they already exist if npm-shrinkwrap is used.
rm -f npm-shrinkwrap.json
npm ${forceOfflineFlag} --nodedir=${nodeSources} ${npmFlags} ${lib.optionalString production "--production"} install
fi
'';
# Builds and composes an NPM package including all its dependencies
buildNodePackage =
{ name
, packageName
, version ? null
, dependencies ? []
, buildInputs ? []
, production ? true
, npmFlags ? ""
, dontNpmInstall ? false
, bypassCache ? false
, reconstructLock ? false
, preRebuild ? ""
, dontStrip ? true
, unpackPhase ? "true"
, buildPhase ? "true"
, meta ? {}
, ... }@args:
let
extraArgs = removeAttrs args [ "name" "dependencies" "buildInputs" "dontStrip" "dontNpmInstall" "preRebuild" "unpackPhase" "buildPhase" "meta" ];
in
stdenv.mkDerivation ({
name = "${name}${if version == null then "" else "-${version}"}";
buildInputs = [ tarWrapper python nodejs ]
++ lib.optional (stdenv.isLinux) utillinux
++ lib.optional (stdenv.isDarwin) libtool
++ buildInputs;
inherit nodejs;
inherit dontStrip; # Stripping may fail a build for some package deployments
inherit dontNpmInstall preRebuild unpackPhase buildPhase;
compositionScript = composePackage args;
pinpointDependenciesScript = pinpointDependenciesOfPackage args;
passAsFile = [ "compositionScript" "pinpointDependenciesScript" ];
installPhase = ''
source ${installPackage}
# Create and enter a root node_modules/ folder
mkdir -p $out/lib/node_modules
cd $out/lib/node_modules
# Compose the package and all its dependencies
source $compositionScriptPath
${prepareAndInvokeNPM { inherit packageName bypassCache reconstructLock npmFlags production; }}
# Create symlink to the deployed executable folder, if applicable
if [ -d "$out/lib/node_modules/.bin" ]
then
ln -s $out/lib/node_modules/.bin $out/bin
# Patch the shebang lines of all the executables
ls $out/bin/* | while read i
do
file="$(readlink -f "$i")"
chmod u+rwx "$file"
patchShebangs "$file"
done
fi
# Create symlinks to the deployed manual page folders, if applicable
if [ -d "$out/lib/node_modules/${packageName}/man" ]
then
mkdir -p $out/share
for dir in "$out/lib/node_modules/${packageName}/man/"*
do
mkdir -p $out/share/man/$(basename "$dir")
for page in "$dir"/*
do
ln -s $page $out/share/man/$(basename "$dir")
done
done
fi
# Run post install hook, if provided
runHook postInstall
'';
meta = {
# default to Node.js' platforms
platforms = nodejs.meta.platforms;
} // meta;
} // extraArgs);
# Builds a node environment (a node_modules folder and a set of binaries)
buildNodeDependencies =
{ name
, packageName
, version ? null
, src
, dependencies ? []
, buildInputs ? []
, production ? true
, npmFlags ? ""
, dontNpmInstall ? false
, bypassCache ? false
, reconstructLock ? false
, dontStrip ? true
, unpackPhase ? "true"
, buildPhase ? "true"
, ... }@args:
let
extraArgs = removeAttrs args [ "name" "dependencies" "buildInputs" ];
in
stdenv.mkDerivation ({
name = "node-dependencies-${name}${if version == null then "" else "-${version}"}";
buildInputs = [ tarWrapper python nodejs ]
++ lib.optional (stdenv.isLinux) utillinux
++ lib.optional (stdenv.isDarwin) libtool
++ buildInputs;
inherit dontStrip; # Stripping may fail a build for some package deployments
inherit dontNpmInstall unpackPhase buildPhase;
includeScript = includeDependencies { inherit dependencies; };
pinpointDependenciesScript = pinpointDependenciesOfPackage args;
passAsFile = [ "includeScript" "pinpointDependenciesScript" ];
installPhase = ''
source ${installPackage}
mkdir -p $out/${packageName}
cd $out/${packageName}
source $includeScriptPath
# Create fake package.json to make the npm commands work properly
cp ${src}/package.json .
chmod 644 package.json
${lib.optionalString bypassCache ''
if [ -f ${src}/package-lock.json ]
then
cp ${src}/package-lock.json .
chmod 644 package-lock.json
fi
''}
# Go to the parent folder to make sure that all packages are pinpointed
cd ..
${lib.optionalString (builtins.substring 0 1 packageName == "@") "cd .."}
${prepareAndInvokeNPM { inherit packageName bypassCache reconstructLock npmFlags production; }}
# Expose the executables that were installed
cd ..
${lib.optionalString (builtins.substring 0 1 packageName == "@") "cd .."}
mv ${packageName} lib
ln -s $out/lib/node_modules/.bin $out/bin
'';
} // extraArgs);
# Builds a development shell
buildNodeShell =
{ name
, packageName
, version ? null
, src
, dependencies ? []
, buildInputs ? []
, production ? true
, npmFlags ? ""
, dontNpmInstall ? false
, bypassCache ? false
, reconstructLock ? false
, dontStrip ? true
, unpackPhase ? "true"
, buildPhase ? "true"
, ... }@args:
let
nodeDependencies = buildNodeDependencies args;
extraArgs = removeAttrs args [ "name" "dependencies" "buildInputs" "dontStrip" "dontNpmInstall" "unpackPhase" "buildPhase" ];
in
stdenv.mkDerivation ({
name = "node-shell-${name}${if version == null then "" else "-${version}"}";
buildInputs = [ python nodejs ] ++ lib.optional (stdenv.isLinux) utillinux ++ buildInputs;
buildCommand = ''
mkdir -p $out/bin
cat > $out/bin/shell <<EOF
#! ${stdenv.shell} -e
$shellHook
exec ${stdenv.shell}
EOF
chmod +x $out/bin/shell
'';
# Provide the dependencies in a development shell through the NODE_PATH environment variable
inherit nodeDependencies;
shellHook = lib.optionalString (dependencies != []) ''
export NODE_PATH=${nodeDependencies}/lib/node_modules
export PATH="${nodeDependencies}/bin:$PATH"
'';
} // extraArgs);
in
{
buildNodeSourceDist = lib.makeOverridable buildNodeSourceDist;
buildNodePackage = lib.makeOverridable buildNodePackage;
buildNodeDependencies = lib.makeOverridable buildNodeDependencies;
buildNodeShell = lib.makeOverridable buildNodeShell;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
#!/usr/bin/env nix-shell
#!nix-shell -p nodePackages.node2nix -i bash
exec node2nix \
-16 \
-i "package.json" \
-l "package-lock.json" \
-o node-packages.nix \
-c node-composition.nix \
--development

4539
py/icalfilter/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
{
"name": "icalfilter-web",
"version": "1.0.0",
"author": "Luke Granger-Brown <lukegb@lukegb.com>",
"license": "Apache-2.0",
"dependencies": {
"@fullcalendar/core": "^5.11.0",
"@fullcalendar/daygrid": "^5.11.0",
"@fullcalendar/icalendar": "^5.11.0",
"@fullcalendar/interaction": "^5.11.0",
"@fullcalendar/list": "^5.11.0",
"@fullcalendar/timegrid": "^5.11.0"
},
"devDependencies": {
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"mini-css-extract-plugin": "^2.6.0",
"webpack": "^5.72.1",
"webpack-cli": "^4.9.2"
}
}

View file

@ -0,0 +1,38 @@
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
module.exports = {
mode: 'production',
devtool: 'source-map',
entry: './main.js',
resolve: {
extensions: [ '.js' ]
},
module: {
rules: [
{
test: /\.css$/,
use: [
{ loader: MiniCssExtractPlugin.loader },
{ loader: 'css-loader', options: { importLoaders: 1 } }
]
}
]
},
output: {
filename: 'main.js',
path: path.join(__dirname, 'dist')
},
plugins: [
new MiniCssExtractPlugin({
filename: 'main.css'
})
],
optimization: {
minimizer: [
`...`,
new CssMinimizerPlugin(),
],
},
}