Compare commits

...

10 commits

10 changed files with 654 additions and 470 deletions

View file

@ -2,6 +2,8 @@ https://voussoir.net
## Mirrors ## Mirrors
https://git.voussoir.net/voussoir/voussoir.net
https://github.com/voussoir/voussoir.net https://github.com/voussoir/voussoir.net
https://gitlab.com/voussoir/voussoir.net https://gitlab.com/voussoir/voussoir.net

View file

@ -86,6 +86,8 @@ def linkchecker(do_external=True):
url = queue.popleft() url = queue.popleft()
if url == 'https://voussoir.net/writing': if url == 'https://voussoir.net/writing':
url = 'https://voussoir.net/writing/' url = 'https://voussoir.net/writing/'
if '/slamming_and_blasting' in url:
continue
result = dotdict.DotDict() result = dotdict.DotDict()
result.exc = None result.exc = None
result.url = url result.url = url
@ -99,7 +101,7 @@ def linkchecker(do_external=True):
if link not in seen: if link not in seen:
queue.append(link) queue.append(link)
seen.add(link) seen.add(link)
elif result.head.headers['content-type'] == 'text/html' and not url.endswith('.html'): elif 'text/html' in result.head.headers['content-type'] and not url.endswith('.html'):
log.debug('GET %s', url) log.debug('GET %s', url)
response = session.get(url) response = session.get(url)
soup = bs4.BeautifulSoup(response.text, 'html.parser') soup = bs4.BeautifulSoup(response.text, 'html.parser')

View file

@ -426,7 +426,7 @@ body.start_eating_that_trashcan .cvitem_details
<div class="cvitem_details"> <div class="cvitem_details">
<p>trkpt is a 24/7 GPS recorder that shows me where I've been.</p> <p>trkpt is a 24/7 GPS recorder that shows me where I've been.</p>
<p><a href="https://voussoir.net/writing/obsessed_with_gpx">https://voussoir.net/writing/obsessed_with_gpx</a></p> <p><a href="https://voussoir.net/writing/obsessed_with_gpx">https://voussoir.net/writing/obsessed_with_gpx</a></p>
<p><a href="https://github.com/voussoir/trkpt">https://github.com/voussoir/trkpt</a></p> <p><a href="https://git.voussoir.net/voussoir/trkpt">https://git.voussoir.net/voussoir/trkpt</a></p>
</div> </div>
<img class="cvitem_logo" style='background-color: transparent !important' src="./cv/trkpt.png"/> <img class="cvitem_logo" style='background-color: transparent !important' src="./cv/trkpt.png"/>
</div> </div>
@ -436,7 +436,7 @@ body.start_eating_that_trashcan .cvitem_details
<div class="cvitem_details"> <div class="cvitem_details">
<p>BringRSS is an RSS client and newsreader with a web interface, made with Flask and SQLite3. RSS is a great way to keep up with your favorite forums, bloggers, podcasts, and newspapers since all the new posts come straight to you in a single place. BringRSS can send news objects to your own Python scripts, allowing for powerful automation like podcast downloading, email notifications, and other more niche features that would be outside the scope of the BringRSS application itself.</p> <p>BringRSS is an RSS client and newsreader with a web interface, made with Flask and SQLite3. RSS is a great way to keep up with your favorite forums, bloggers, podcasts, and newspapers since all the new posts come straight to you in a single place. BringRSS can send news objects to your own Python scripts, allowing for powerful automation like podcast downloading, email notifications, and other more niche features that would be outside the scope of the BringRSS application itself.</p>
<p><a href="https://bringrss.voussoir.net">Live demo</a></p> <p><a href="https://bringrss.voussoir.net">Live demo</a></p>
<p><a href="https://github.com/voussoir/bringrss">https://github.com/voussoir/bringrss</a></p> <p><a href="https://git.voussoir.net/voussoir/bringrss">https://git.voussoir.net/voussoir/bringrss</a></p>
</div> </div>
<img class="cvitem_logo" src="./cv/bringrss.png"/> <img class="cvitem_logo" src="./cv/bringrss.png"/>
</div> </div>
@ -445,7 +445,7 @@ body.start_eating_that_trashcan .cvitem_details
<h3 class="cvitem_title" id="ycdl">YCDL</h3> <h3 class="cvitem_title" id="ycdl">YCDL</h3>
<div class="cvitem_details"> <div class="cvitem_details">
<p>YoutubeChannelDownloader was born out of a dissatisfaction with YouTube's own interface for keeping track of which videos I have already watched, as well as a desire to integrate with youtube-dl. YCDL makes it easy for me to watch through a channel's catalog of videos, picking which ones I'd like to download while ignoring the others. Plus, as it creates an offline database, it will retain metadata about videos even after they are removed or deleted from the original YouTube channel.</p> <p>YoutubeChannelDownloader was born out of a dissatisfaction with YouTube's own interface for keeping track of which videos I have already watched, as well as a desire to integrate with youtube-dl. YCDL makes it easy for me to watch through a channel's catalog of videos, picking which ones I'd like to download while ignoring the others. Plus, as it creates an offline database, it will retain metadata about videos even after they are removed or deleted from the original YouTube channel.</p>
<p><a href="https://github.com/voussoir/ycdl">https://github.com/voussoir/ycdl</a></p> <p><a href="https://git.voussoir.net/voussoir/ycdl">https://git.voussoir.net/voussoir/ycdl</a></p>
</div> </div>
<img class="cvitem_logo" src="./cv/ycdl.png"/> <img class="cvitem_logo" src="./cv/ycdl.png"/>
</div> </div>
@ -456,7 +456,7 @@ body.start_eating_that_trashcan .cvitem_details
<p>Etiquette is a tag-based file organization system with a web interface, built with Flask and SQLite3. Tag-based systems solve problems that a traditional folder hierarchy can't: <em>which folder should a file go in if it equally belongs in both?</em> and <em>how do I make my files searchable without littering the filenames themselves with keywords?</em></p> <p>Etiquette is a tag-based file organization system with a web interface, built with Flask and SQLite3. Tag-based systems solve problems that a traditional folder hierarchy can't: <em>which folder should a file go in if it equally belongs in both?</em> and <em>how do I make my files searchable without littering the filenames themselves with keywords?</em></p>
<p>Etiquette is unique because the tags themselves are hierarchical. By tagging one of your vacation photos with the <code>family.parents.dad</code> tag, it will automatically appear in searches for <code>family.parents</code> and <code>family</code> as well. A traditional folder system, here called albums, is available to bundle files that always belong together without creating a bespoke tag to represent that bundle. Regardless, the files on disk are never modified.</p> <p>Etiquette is unique because the tags themselves are hierarchical. By tagging one of your vacation photos with the <code>family.parents.dad</code> tag, it will automatically appear in searches for <code>family.parents</code> and <code>family</code> as well. A traditional folder system, here called albums, is available to bundle files that always belong together without creating a bespoke tag to represent that bundle. Regardless, the files on disk are never modified.</p>
<p><a href="https://etiquette.voussoir.net">Live demo</a></p> <p><a href="https://etiquette.voussoir.net">Live demo</a></p>
<p><a href="https://github.com/voussoir/etiquette">https://github.com/voussoir/etiquette</a></p> <p><a href="https://git.voussoir.net/voussoir/etiquette">https://git.voussoir.net/voussoir/etiquette</a></p>
</div> </div>
<img class="cvitem_logo" src="./cv/etiquette.png"/> <img class="cvitem_logo" src="./cv/etiquette.png"/>
</div> </div>
@ -466,7 +466,7 @@ body.start_eating_that_trashcan .cvitem_details
<div class="cvitem_details"> <div class="cvitem_details">
<p>Timesearch is a package of tools for archiving data from reddit.com. Subreddits, user posts, comments, CSS files, and community wiki files can be downloaded and easily updated.</p> <p>Timesearch is a package of tools for archiving data from reddit.com. Subreddits, user posts, comments, CSS files, and community wiki files can be downloaded and easily updated.</p>
<p>Originally, it used the <code>timestamp</code> query parameter of reddit's elasticsearch, but since that feature's removal Timesearch instead queries the third-party pushshift.io database for preliminary data, then queries reddit for updated information about each item.</p> <p>Originally, it used the <code>timestamp</code> query parameter of reddit's elasticsearch, but since that feature's removal Timesearch instead queries the third-party pushshift.io database for preliminary data, then queries reddit for updated information about each item.</p>
<p><a href="https://github.com/voussoir/timesearch">https://github.com/voussoir/timesearch</a></p></div> <p><a href="https://git.voussoir.net/voussoir/timesearch">https://git.voussoir.net/voussoir/timesearch</a></p></div>
<img class="cvitem_logo" src="./cv/timesearch.png"/> <img class="cvitem_logo" src="./cv/timesearch.png"/>
</div> </div>
@ -474,7 +474,7 @@ body.start_eating_that_trashcan .cvitem_details
<h3 class="cvitem_title" id="voussoirkit">voussoirkit</h3> <h3 class="cvitem_title" id="voussoirkit">voussoirkit</h3>
<div class="cvitem_details"> <div class="cvitem_details">
<p>The voussoirkit library contains code that I have found useful to include in my other projects. Everything from <code>bytestring</code> that converts integer numbers of bytes into "3.145 MiB" strings, to <code>pathclass</code> and <code>spinal</code> which provide object-oriented file and directory operations and copy routines. Some modules like <code>winglob</code> boost cross-compatibility by smoothing over differences between Windows and Unix. This way I can easily deploy new features and bug fixes to all my programs.</p> <p>The voussoirkit library contains code that I have found useful to include in my other projects. Everything from <code>bytestring</code> that converts integer numbers of bytes into "3.145 MiB" strings, to <code>pathclass</code> and <code>spinal</code> which provide object-oriented file and directory operations and copy routines. Some modules like <code>winglob</code> boost cross-compatibility by smoothing over differences between Windows and Unix. This way I can easily deploy new features and bug fixes to all my programs.</p>
<p><a href="https://github.com/voussoir/voussoirkit">https://github.com/voussoir/voussoirkit</a></p> <p><a href="https://git.voussoir.net/voussoir/voussoirkit">https://git.voussoir.net/voussoir/voussoirkit</a></p>
</div> </div>
<img class="cvitem_logo" src="./cv/voussoirkit.png"/> <img class="cvitem_logo" src="./cv/voussoirkit.png"/>
</div> </div>
@ -483,7 +483,7 @@ body.start_eating_that_trashcan .cvitem_details
<h3 class="cvitem_title" id="hnarchive">HN Archive</h3> <h3 class="cvitem_title" id="hnarchive">HN Archive</h3>
<div class="cvitem_details"> <div class="cvitem_details">
<p>hnarchive is a tool that downloads all submissions and comments on <a href="https://news.ycombinator.com">Hacker News</a>. HN is a forum that is mostly focused on technology and entrepreneurship. Although I am not entirely sure if all participants are <a href="https://voussoir.net/writing/cyborgs_on_hn">human</a>, it is a knowledgebase of considerable quality and in my opinion worth preserving.</p> <p>hnarchive is a tool that downloads all submissions and comments on <a href="https://news.ycombinator.com">Hacker News</a>. HN is a forum that is mostly focused on technology and entrepreneurship. Although I am not entirely sure if all participants are <a href="https://voussoir.net/writing/cyborgs_on_hn">human</a>, it is a knowledgebase of considerable quality and in my opinion worth preserving.</p>
<p><a href="https://github.com/voussoir/hnarchive">https://github.com/voussoir/hnarchive</a></p> <p><a href="https://git.voussoir.net/voussoir/hnarchive">https://git.voussoir.net/voussoir/hnarchive</a></p>
</div> </div>
<img class="cvitem_logo" src="./cv/hnarchive.png"/> <img class="cvitem_logo" src="./cv/hnarchive.png"/>
</div> </div>
@ -493,8 +493,8 @@ body.start_eating_that_trashcan .cvitem_details
<div class="cvitem_details"> <div class="cvitem_details">
<p>I use the wonderful program Sigil to edit epub files. Sigil has a python plugin system for which I have written a few modules. But, since the plugins can only operate on one book at a time while it is open in Sigil, I needed something a little different to edit epub files en masse.</p> <p>I use the wonderful program Sigil to edit epub files. Sigil has a python plugin system for which I have written a few modules. But, since the plugins can only operate on one book at a time while it is open in Sigil, I needed something a little different to edit epub files en masse.</p>
<p>Epubfile is a simple library for automatically processing epubs. It comes with a number of builtin routines for what I do most often: merging multiple epubs into a single file, normalizing the internal file structure, and renaming the cover image file to leverage CBXShell so I get thumbnails in Windows Explorer.</p> <p>Epubfile is a simple library for automatically processing epubs. It comes with a number of builtin routines for what I do most often: merging multiple epubs into a single file, normalizing the internal file structure, and renaming the cover image file to leverage CBXShell so I get thumbnails in Windows Explorer.</p>
<p><a href="https://github.com/voussoir/epubfile">https://github.com/voussoir/epubfile</a></p> <p><a href="https://git.voussoir.net/voussoir/epubfile">https://git.voussoir.net/voussoir/epubfile</a></p>
<p><a href="https://github.com/voussoir/sigilplugins">https://github.com/voussoir/sigilplugins</a></p> <p><a href="https://git.voussoir.net/voussoir/sigilplugins">https://git.voussoir.net/voussoir/sigilplugins</a></p>
</div> </div>
<img class="cvitem_logo" src="./cv/epub.png"/> <img class="cvitem_logo" src="./cv/epub.png"/>
</div> </div>
@ -551,6 +551,7 @@ body.start_eating_that_trashcan .cvitem_details
<h3 class="cvitem_title" id="gitmirrors">Git mirrors</h3> <h3 class="cvitem_title" id="gitmirrors">Git mirrors</h3>
<div class="cvitem_details"> <div class="cvitem_details">
<p>Eggs. Baskets.</p> <p>Eggs. Baskets.</p>
<p><a href="https://git.voussoir.net">https://git.voussoir.net</a></p>
<p><a href="https://github.com/voussoir">https://github.com/voussoir</a></p> <p><a href="https://github.com/voussoir">https://github.com/voussoir</a></p>
<p><a href="https://codeberg.org/voussoir">https://codeberg.org/voussoir</a></p> <p><a href="https://codeberg.org/voussoir">https://codeberg.org/voussoir</a></p>
<p><a href="https://gitlab.com/voussoir">https://gitlab.com/voussoir</a></p> <p><a href="https://gitlab.com/voussoir">https://gitlab.com/voussoir</a></p>
@ -882,6 +883,7 @@ const SPLASHES = [
"impulse 101", // https://developer.valvesoftware.com/wiki/Impulse "impulse 101", // https://developer.valvesoftware.com/wiki/Impulse
"in this moment, i am euphoric", // aalewis https://knowyourmeme.com/memes/in-this-moment-i-am-euphoric "in this moment, i am euphoric", // aalewis https://knowyourmeme.com/memes/in-this-moment-i-am-euphoric
"infinity is not necessarily all-encompassing", "infinity is not necessarily all-encompassing",
"information wants to be free", // https://en.wikipedia.org/wiki/Information_wants_to_be_free
"inject the memes into my bloodstream", // Filthy Frank, Meme Machine https://youtu.be/wl-LeTFM8zo "inject the memes into my bloodstream", // Filthy Frank, Meme Machine https://youtu.be/wl-LeTFM8zo
"insane in the membrane", // Cypress Hill, Insane in the Brain https://youtu.be/RijB8wnJCN0 "insane in the membrane", // Cypress Hill, Insane in the Brain https://youtu.be/RijB8wnJCN0
"is a little overbearing", "is a little overbearing",
@ -1153,6 +1155,7 @@ const SPLASHES = [
"the rivers and the hills, the forests and the streams", // Walking in the Air https://youtu.be/X986dthrhaQ "the rivers and the hills, the forests and the streams", // Walking in the Air https://youtu.be/X986dthrhaQ
"the sheriff is near", // Blazing Saddles (1974) https://youtu.be/sAELs42aZt4 "the sheriff is near", // Blazing Saddles (1974) https://youtu.be/sAELs42aZt4
"the squeaky wheel gets hammered down", "the squeaky wheel gets hammered down",
"the sun's as warm as a baked potato", // Cannibal! The Musical https://www.cannibalthemusical.net/songs.shtml
"the water is extracted for use in rivers", // How it's Unmade - Oreo Cookies https://youtu.be/cJyGoGPXTj4 "the water is extracted for use in rivers", // How it's Unmade - Oreo Cookies https://youtu.be/cJyGoGPXTj4
"the waves part and they engulf me and the water is warm", // You could stop at five or six stores https://youtu.be/YCeQLeQiRP4 "the waves part and they engulf me and the water is warm", // You could stop at five or six stores https://youtu.be/YCeQLeQiRP4
"the word itself makes some men uncomfortable", // The Big Lebowski (1998) https://youtu.be/xs3OWJ53rHE "the word itself makes some men uncomfortable", // The Big Lebowski (1998) https://youtu.be/xs3OWJ53rHE

View file

@ -1,330 +0,0 @@
:root
{
--color_bodybg: #272822;
--color_codebg: rgba(255, 255, 255, 0.05);
--color_codeborder: rgba(255, 255, 255, 0.2);
--color_h1bg: #284142;
--color_htmlbg: #1b1c18;
--color_blockquotebg: rgba(0, 0, 0, 0.2);
--color_blockquoteedge: rgba(255, 255, 255, 0.2);
--color_inlinecodebg: rgba(255, 255, 255, 0.1);
--color_link: #ae81ff;
--color_maintext: #ddd;
}
*, *:before, *:after
{
box-sizing: inherit;
}
html
{
height: 100vh;
box-sizing: border-box;
background-color: var(--color_htmlbg);
color: var(--color_maintext);
font-family: Verdana, sans-serif;
font-size: 10pt;
margin: 0;
}
body
{
min-height: 100%;
width: fit-content;
margin-left: auto;
margin-right: auto;
margin-top: 0;
margin-bottom: 0;
padding: 8px;
}
body.noscrollbar::-webkit-scrollbar
{
display: none;
}
body.noscrollbar
{
scrollbar-width: none;
}
header
{
width: 100%;
max-width: 120em;
margin-left: auto;
margin-right: auto;
text-align: end;
}
header > *
{
display: inline-block;
padding: 16px;
background-color: var(--color_bodybg);
}
.album,
.photograph
{
position: relative;
margin-left: auto;
margin-right: auto;
margin-top: 8vh;
margin-bottom: 8vh;
}
.photograph
{
padding: 2vh;
background-color: var(--color_bodybg);
border-radius: 16px;
}
article .photograph:first-of-type
{
margin-top: 0;
}
article .photograph:last-of-type
{
margin-bottom: 0;
}
.photograph img
{
max-height: 92vh;
border-radius: 8px;
}
article .morelink
{
font-size: 2em;
text-align: center;
margin-top: 0;
}
@media not print
{
.photograph
{
box-shadow: #000 0px 0px 40px -10px;
}
}
@media screen and (min-width: 600px)
{
article
{
width: fit-content;
}
}
@media screen and (max-width: 600px)
{
.photograph
{
box-shadow: none;
}
}
@media not all and (pointer: fine)
{
#keyboardhint,
#scrollbartoggle
{
display: none;
}
}
h1, h2, h3, h4, h5
{
margin-bottom: 0;
padding: 8px;
}
h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child
{
margin-top: 0;
}
h2, h3, h4, h5
{
border-bottom: 1px solid var(--color_maintext);
/*background-color: var(--color_h1bg);*/
}
h1 + p
{
margin-top: 0;
}
p:last-child
{
margin-bottom: 0;
}
h1 {font-size: 2.00em;} h1 * {font-size: inherit;}
h2 {font-size: 1.75em;} h2 * {font-size: inherit;}
h3 {font-size: 1.50em;} h3 * {font-size: inherit;}
h4 {font-size: 1.25em;} h4 * {font-size: inherit;}
h5 {font-size: 1.00em;} h5 * {font-size: inherit;}
.header_anchor_link {display: none; font-size: 1.0em; text-decoration: none}
h1:hover > .header_anchor_link {display: initial;}
h2:hover > .header_anchor_link {display: initial;}
h3:hover > .header_anchor_link {display: initial;}
h4:hover > .header_anchor_link {display: initial;}
h5:hover > .header_anchor_link {display: initial;}
a
{
color: var(--color_link);
cursor: pointer;
}
article *
{
max-width: 100%;
word-wrap: break-word;
}
#table_of_contents
{
border: 1px solid var(--color_blockquoteedge);
padding-top: 8px;
padding-bottom: 8px;
border-radius: 8px;
}
blockquote
{
background-color: var(--color_blockquotebg);
margin-inline-start: 0;
margin-inline-end: 0;
border-left: 4px solid var(--color_blockquoteedge);
padding: 8px;
padding-inline-start: 20px;
padding-inline-end: 20px;
}
table
{
border-collapse: collapse;
font-size: 1em;
}
table, table th, table td
{
border: 1px solid var(--color_maintext);
}
table th, table td
{
padding: 4px;
}
ol ol, ul ul, ol ul, ul ol
{
padding-inline-start: 20px;
}
*:not(pre) > code
{
background-color: var(--color_inlinecodebg);
border-radius: 4px;
line-height: 1.5;
padding-left: 4px;
padding-right: 4px;
}
pre
{
padding: 8px;
border: 1px solid var(--color_codeborder);
background-color: var(--color_codebg);
overflow-x: auto;
}
code,
pre,
.highlight *
{
font-family: monospace;
}
/*
Thank you richleland for pre-building this Monokai style.
https://github.com/richleland/pygments-css
*/
:root
{
--color_monokai_bg: #272822;
--color_monokai_purple: #ae81ff;
--color_monokai_green: #a6e22e;
--color_monokai_pink: #f92672;
--color_monokai_white: #f8f8f2;
--color_monokai_orange: #fd971f;
--color_monokai_yellow: #e6db74;
--color_monokai_blue: #66d9ef;
}
.highlight .hll { background-color: #49483e }
.highlight { background-color: var(--color_monokai_bg); color: var(--color_monokai_white) }
.highlight .c { color: #75715e } /* Comment */
.highlight .err { color: #960050; background-color: #1e0010 } /* Error */
.highlight .k { color: var(--color_monokai_pink) } /* Keyword */
.highlight .l { color: var(--color_monokai_purple) } /* Literal */
.highlight .n { color: var(--color_monokai_white) } /* Name */
.highlight .o { color: var(--color_monokai_pink) } /* Operator */
.highlight .p { color: var(--color_monokai_white) } /* Punctuation */
.highlight .ch { color: #75715e } /* Comment.Hashbang */
.highlight .cm { color: #75715e } /* Comment.Multiline */
.highlight .cp { color: #75715e } /* Comment.Preproc */
.highlight .cpf { color: #75715e } /* Comment.PreprocFile */
.highlight .c1 { color: #75715e } /* Comment.Single */
.highlight .cs { color: #75715e } /* Comment.Special */
.highlight .gd { color: var(--color_monokai_pink) } /* Generic.Deleted */
.highlight .ge { font-style: italic } /* Generic.Emph */
.highlight .gi { color: var(--color_monokai_green) } /* Generic.Inserted */
.highlight .gs { font-weight: bold } /* Generic.Strong */
.highlight .gu { color: #75715e } /* Generic.Subheading */
.highlight .kc { color: var(--color_monokai_purple) } /* Keyword.Constant */
.highlight .kd { color: var(--color_monokai_blue) } /* Keyword.Declaration */
.highlight .kn { color: var(--color_monokai_pink) } /* Keyword.Namespace */
.highlight .kp { color: var(--color_monokai_blue) } /* Keyword.Pseudo */
.highlight .kr { color: var(--color_monokai_blue) } /* Keyword.Reserved */
.highlight .kt { color: var(--color_monokai_blue) } /* Keyword.Type */
.highlight .ld { color: var(--color_monokai_yellow) } /* Literal.Date */
.highlight .m { color: var(--color_monokai_purple) } /* Literal.Number */
.highlight .s { color: var(--color_monokai_yellow) } /* Literal.String */
.highlight .na { color: var(--color_monokai_white) } /* Name.Attribute */
.highlight .narg {color: var(--color_monokai_orange) } /* Custom Name.Argument */
.highlight .nb { color: var(--color_monokai_blue) } /* Name.Builtin */
.highlight .nc { color: var(--color_monokai_white) } /* Name.Class */
.highlight .no { color: var(--color_monokai_blue) } /* Name.Constant */
.highlight .nd { color: var(--color_monokai_green) } /* Name.Decorator */
.highlight .ni { color: var(--color_monokai_white) } /* Name.Entity */
.highlight .ne { color: var(--color_monokai_blue) } /* Name.Exception */
.highlight .nf { color: var(--color_monokai_green) } /* Name.Function */
.highlight .nl { color: var(--color_monokai_white) } /* Name.Label */
.highlight .nn { color: var(--color_monokai_white) } /* Name.Namespace */
.highlight .nx { color: var(--color_monokai_green) } /* Name.Other */
.highlight .py { color: var(--color_monokai_white) } /* Name.Property */
.highlight .nt { color: var(--color_monokai_pink) } /* Name.Tag */
.highlight .nv { color: var(--color_monokai_white) } /* Name.Variable */
.highlight .ow { color: var(--color_monokai_pink) } /* Operator.Word */
.highlight .w { color: var(--color_monokai_white) } /* Text.Whitespace */
.highlight .mb { color: var(--color_monokai_purple) } /* Literal.Number.Bin */
.highlight .mf { color: var(--color_monokai_purple) } /* Literal.Number.Float */
.highlight .mh { color: var(--color_monokai_purple) } /* Literal.Number.Hex */
.highlight .mi { color: var(--color_monokai_purple) } /* Literal.Number.Integer */
.highlight .mo { color: var(--color_monokai_purple) } /* Literal.Number.Oct */
.highlight .sa { color: var(--color_monokai_white) } /* Literal.String.Affix */
.highlight .sb { color: var(--color_monokai_yellow) } /* Literal.String.Backtick */
.highlight .sc { color: var(--color_monokai_yellow) } /* Literal.String.Char */
.highlight .dl { color: var(--color_monokai_yellow) } /* Literal.String.Delimiter */
.highlight .sd { color: var(--color_monokai_yellow) } /* Literal.String.Doc */
.highlight .s2 { color: var(--color_monokai_yellow) } /* Literal.String.Double */
.highlight .se { color: var(--color_monokai_purple) } /* Literal.String.Escape */
.highlight .sh { color: var(--color_monokai_yellow) } /* Literal.String.Heredoc */
.highlight .si { color: var(--color_monokai_yellow) } /* Literal.String.Interpol */
.highlight .sx { color: var(--color_monokai_yellow) } /* Literal.String.Other */
.highlight .sr { color: var(--color_monokai_yellow) } /* Literal.String.Regex */
.highlight .s1 { color: var(--color_monokai_yellow) } /* Literal.String.Single */
.highlight .ss { color: var(--color_monokai_yellow) } /* Literal.String.Symbol */
.highlight .bp { color: var(--color_monokai_white) } /* Name.Builtin.Pseudo */
.highlight .fm { color: var(--color_monokai_blue) } /* Name.Function.Magic */
.highlight .vc { color: var(--color_monokai_white) } /* Name.Variable.Class */
.highlight .vg { color: var(--color_monokai_white) } /* Name.Variable.Global */
.highlight .vi { color: var(--color_monokai_white) } /* Name.Variable.Instance */
.highlight .vm { color: var(--color_monokai_white) } /* Name.Variable.Magic */
.highlight .il { color: var(--color_monokai_purple) } /* Literal.Number.Integer.Long */

View file

@ -1,18 +1,43 @@
import boto3
import io
import jinja2 import jinja2
import PIL.Image import PIL.Image
import sys import sys
import urllib.parse
import etiquette
import r2_credentials
from voussoirkit import dotdict from voussoirkit import dotdict
from voussoirkit import imagetools from voussoirkit import imagetools
from voussoirkit import pathclass from voussoirkit import pathclass
from voussoirkit import spinal from voussoirkit import spinal
from voussoirkit import vlogging
log = vlogging.get_logger(__name__, 'photography_generate')
pdb = etiquette.photodb.PhotoDB('D:\\Documents\\Photos\\_etiquette')
s3 = boto3.resource('s3',
endpoint_url = f'https://{r2_credentials.get_account_id()}.r2.cloudflarestorage.com',
aws_access_key_id = r2_credentials.get_access_key(),
aws_secret_access_key = r2_credentials.get_access_secret(),
)
bucket = s3.Bucket('voussoir')
S3_EXISTING_FILES = set(item.key for item in bucket.objects.filter(Prefix="photography/"))
PUBLISH_TAGNAME = 'voussoir_net_publish'
HEADLINER_TAGNAME = 'voussoir_net_headliner'
PHOTOGRAPHY_ROOTDIR = pathclass.Path(__file__).parent PHOTOGRAPHY_ROOTDIR = pathclass.Path(__file__).parent
ATOM_FILE = PHOTOGRAPHY_ROOTDIR.with_child('photography.atom') ATOM_FILE = PHOTOGRAPHY_ROOTDIR.with_child('photography.atom')
DOMAIN_ROOTDIR = PHOTOGRAPHY_ROOTDIR.parent DOMAIN_ROOTDIR = PHOTOGRAPHY_ROOTDIR.parent
CSS_CONTENT = PHOTOGRAPHY_ROOTDIR.with_child('dark.css').read('r', encoding='utf-8') DOMAIN_WEBROOT = 'https://voussoir.net'
DOMAIN_WEBROOT = ('file:///' + DOMAIN_ROOTDIR.absolute_path) if '--test' in sys.argv else 'https://voussoir.net' PHOTOGRAPHY_WEBROOT = 'https://voussoir.net/photography'
DOMAIN_WEBROOT = DOMAIN_WEBROOT.replace('\\', '/') S3_WEBROOT = 'https://files.voussoir.net'
SIZE_SMALL = 1440
SIZE_TINY = 360
def webpath(path, anchor=None): def webpath(path, anchor=None):
path = path.relative_to(DOMAIN_ROOTDIR, simple=True).replace('\\', '/').lstrip('/') path = path.relative_to(DOMAIN_ROOTDIR, simple=True).replace('\\', '/').lstrip('/')
@ -22,92 +47,129 @@ def webpath(path, anchor=None):
return path return path
class Photo: class Photo:
def __init__(self, filepath): def __init__(self, etq_photo, etq_album=None):
self.filepath = filepath self.etq_photo = etq_photo
self.thumbnail = make_thumbnail(filepath) self.article_id = self.etq_photo.real_path.replace_extension('').basename
self.article_id = filepath.replace_extension('').basename
self.anchor = f'#{self.article_id}' if etq_album is None:
self.published = imagetools.get_exif_datetime(filepath) parent_key = 'photography'
else:
parent_key = f'photography/{etq_album.title}'
self.s3_key = f'{parent_key}/{self.etq_photo.real_path.basename}'
self.small_key = f'{parent_key}/small_{self.etq_photo.real_path.basename}'
self.tiny_key = f'{parent_key}/tiny_{self.etq_photo.real_path.basename}'
self.color_class = 'monochrome' if self.etq_photo.has_tag('monochrome') else ''
self.s3_exists = self.s3_key in S3_EXISTING_FILES;
self.img_url = f'{S3_WEBROOT}/{self.s3_key}'
self.small_url = f'{S3_WEBROOT}/{self.small_key}'
self.tiny_url = f'{S3_WEBROOT}/{self.tiny_key}'
self.anchor_url = f'{DOMAIN_WEBROOT}/{parent_key}#{self.article_id}'
self.published = imagetools.get_exif_datetime(self.etq_photo.real_path)
def prepare(self):
if not self.s3_exists:
self.s3_upload()
def make_thumbnail(self, size):
image = PIL.Image.open(self.etq_photo.real_path.absolute_path)
icc = image.info.get('icc_profile')
(image_width, image_height) = image.size
exif = image.getexif()
(width, height) = imagetools.fit_into_bounds(image_width, image_height, size, size)
image = image.resize((width, height), PIL.Image.LANCZOS)
bio = io.BytesIO()
image.save(bio, format='jpeg', quality=75, exif=exif, icc_profile=icc)
bio.seek(0)
return bio
def s3_upload(self):
log.info('Uploading %s as %s', self.etq_photo.real_path.absolute_path, self.s3_key)
bucket.upload_fileobj(self.make_thumbnail(SIZE_SMALL), self.small_key)
bucket.upload_fileobj(self.make_thumbnail(SIZE_TINY), self.tiny_key)
bucket.upload_fileobj(self.etq_photo.real_path.open('rb'), self.s3_key)
self.s3_exists = True
def render_web(self, index=None, totalcount=None):
if totalcount is not None:
number_tag = f'<span class="number_tag">#{index}/{totalcount}</a>'
else:
number_tag = ''
def render_web(self, relative_directory=None):
return f''' return f'''
<article id="{self.article_id}" class="photograph"> <article id="{self.article_id}" class="photograph {self.color_class}">
<a href="{webpath(self.filepath)}" target="_blank"><img src="{webpath(self.thumbnail)}" loading="lazy"/></a> <a href="{self.img_url}" target="_blank"><img src="{self.small_url}" loading="lazy"/></a>
{number_tag}
</article> </article>
''' '''
def render_atom(self): def render_atom(self):
href = webpath(PHOTOGRAPHY_ROOTDIR, anchor=self.anchor)
imgsrc = webpath(self.thumbnail)
return f''' return f'''
<id>{self.article_id}</id> <id>{self.article_id}</id>
<title>{self.article_id}</title> <title>{self.article_id}</title>
<link rel="alternate" type="text/html" href="{href}"/> <link rel="alternate" type="text/html" href="{self.anchor_url}"/>
<updated>{self.published.isoformat()}</updated> <updated>{self.published.isoformat()}</updated>
<content type="html"> <content type="html">
<![CDATA[ <![CDATA[
<a href="{href}"><img src="{imgsrc}"/></a> <a href="{self.img_url}"><img src="{self.small_url}"/></a>
]]> ]]>
</content> </content>
''' '''
class Album: class Album:
def __init__(self, path): def __init__(self, etq_album):
self.path = path self.etq_album = etq_album
self.article_id = path.basename self.article_id = self.etq_album.title
self.link = webpath(path) self.photos = list(self.etq_album.get_photos())
self.published = imagetools.get_exif_datetime(sorted(path.glob_files('*.jpg'))[0]) self.photos = [p for p in self.photos if p.has_tag(PUBLISH_TAGNAME)]
self.photos = list(spinal.walk( self.photos = [Photo(etq_photo=photo, etq_album=self.etq_album) for photo in self.photos]
self.path, self.photos.sort(key=lambda p: p.published)
glob_filenames={'*.jpg'}, # self.link = webpath(path)
exclude_filenames={'*_small*'}, self.web_url = f'{PHOTOGRAPHY_WEBROOT}/{self.article_id}'
recurse=False, self.published = self.photos[0].published
yield_directories=False,
))
self.photos.sort(key=lambda file: file.basename)
self.photos = [Photo(file) for file in self.photos]
def render_web(self): def prepare(self):
firsts = self.photos[:5] for photo in self.photos:
remaining = self.photos[5:] photo.prepare()
if remaining:
next_after_more = remaining[0] def render_web(self, index=None, totalcount=None):
else: headliners = [p for p in self.photos if p.etq_photo.has_tag(HEADLINER_TAGNAME)]
next_after_more = None
return jinja2.Template(''' return jinja2.Template('''
<article id="{{article_id}}" class="album"> <article id="{{article_id}}" class="album">
<h1><a href="{{album_path}}">{{directory.basename}}</a></h1> <h1><a href="{{web_url}}">{{article_id}}</a></h1>
{% for photo in firsts %} <div class="albumphotos">
{% for photo in headliners %}
{{photo.render_web()}} {{photo.render_web()}}
{% endfor %} {% endfor %}
{% if remaining > 0 %} <div class="album_tinies">
<p class="morelink"><a href="{{album_path}}{{next_after_more.anchor}}">{{remaining}} more</a></p> {% for photo in photos %}
{% endif %} <a class="tiny_thumbnail {{photo.color_class}}" href="{{photo.anchor_url}}"><img src="{{photo.tiny_url}}" loading="lazy"/></a>
{% endfor %}
</div>
</div>
</article> </article>
''').render( ''').render(
article_id=self.article_id, article_id=self.article_id,
directory=self.path, web_url=self.web_url,
album_path=webpath(self.path), photos=self.photos,
next_after_more=next_after_more, headliners=headliners,
firsts=firsts,
remaining=len(remaining),
) )
def render_atom(self): def render_atom(self):
photos = [] photos = []
for photo in self.photos: for photo in self.photos:
href = webpath(photo.filepath) line = f'<article><a href="{photo.anchor_url}"><img src="{photo.small_url}" loading="lazy"/></a>'.replace('\\', '/')
imgsrc = webpath(photo.thumbnail)
line = f'<article><a href="{href}"><img src="{imgsrc}"/></a>'.replace('\\', '/')
photos.append(line) photos.append(line)
photos = '\n'.join(photos) photos = '\n'.join(photos)
return f''' return f'''
<id>{self.article_id}</id> <id>{self.article_id}</id>
<title>{self.article_id}</title> <title>{self.article_id}</title>
<link rel="alternate" type="text/html" href="{self.link}"/> <link rel="alternate" type="text/html" href="{self.web_url}"/>
<updated>{self.published.isoformat()}</updated> <updated>{self.published.isoformat()}</updated>
<content type="html"> <content type="html">
<![CDATA[ <![CDATA[
@ -126,30 +188,12 @@ def write(path, content):
print(path.absolute_path) print(path.absolute_path)
path.write('w', content, encoding='utf-8') path.write('w', content, encoding='utf-8')
def write_directory_index(directory): def make_webpage(items, is_root, doctitle):
rss_link = webpath(ATOM_FILE) if directory == PHOTOGRAPHY_ROOTDIR else None rss_link = f'{PHOTOGRAPHY_WEBROOT}/{ATOM_FILE.basename}' if is_root else None
back_link = webpath(PHOTOGRAPHY_ROOTDIR) if directory != PHOTOGRAPHY_ROOTDIR else None back_link = None if is_root else PHOTOGRAPHY_WEBROOT
sort_reverse = directory == PHOTOGRAPHY_ROOTDIR sort_reverse = is_root
items = list(spinal.walk( html = jinja2.Template('''
directory,
glob_filenames={'*.jpg'},
exclude_filenames={'*_small*'},
recurse=False,
yield_directories=False,
)) + list(directory.listdir_directories())
items2 = []
for item in items:
if item.is_file:
items2.append(Photo(item))
else:
items2.append(Album(item))
items = items2
items.sort(key=lambda item: item.published, reverse=sort_reverse)
page = jinja2.Template('''
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -158,10 +202,229 @@ def write_directory_index(directory):
{% if rss_link %} {% if rss_link %}
<link rel="alternate" type="application/atom+xml" href="{{rss_link}}"/> <link rel="alternate" type="application/atom+xml" href="{{rss_link}}"/>
{% endif %} {% endif %}
<title>{{directory.basename}}</title> <title>{{doctitle}}</title>
<style> <style>
{{css_content}} :root
{
--color_bodybg: #272822;
--color_htmlbg: #1b1c18;
--color_link: #ae81ff;
--color_maintext: #ddd;
--img_borderradius: 16px;
--img_borderradius_tiny: 4px;
--img_sepia: 0%;
--img_huerotate: 0deg;
--img_saturate: 100%;
--img_blur: 0px;
--img_mixblendmode: normal;
}
*, *:before, *:after
{
box-sizing: inherit;
margin: 0;
}
.hidden
{
display: none !important;
}
html
{
height: 100vh;
box-sizing: border-box;
background-color: var(--color_htmlbg);
color: var(--color_maintext);
font-family: Verdana, sans-serif;
font-size: 10pt;
}
body
{
width: 100em;
max-width: 100%;
margin-left: auto;
margin-right: auto;
margin-top: 0;
margin-bottom: 0;
padding: 8px;
padding-bottom:8vh;
display: grid;
grid-auto-flow: row;
grid-row-gap: 12vh;
}
body.noscrollbar::-webkit-scrollbar
{
display: none;
}
body.noscrollbar
{
scrollbar-width: none;
}
a
{
color: var(--color_link);
cursor: pointer;
}
h1
{
text-align: center;
}
article *
{
max-width: 100%;
word-wrap: break-word;
}
header
{
width: 100%;
max-width: 120em;
margin-left: auto;
margin-right: auto;
text-align: end;
}
header > *
{
display: inline-block;
padding: 16px;
background-color: var(--color_bodybg);
}
.album,
.photograph,
.album_tinies
{
position: relative;
margin-left: auto;
margin-right: auto;
}
.album
{
width: 100%;
}
.album .albumphotos
{
width: 100%;
display: grid;
grid-auto-flow: row;
grid-row-gap: 12vh;
}
.photograph
{
width: fit-content;
}
.photograph img
{
display: block;
max-height: 92vh;
border-radius: var(--img_borderradius);
border: 1.25vh solid var(--color_bodybg);
filter: hue-rotate(var(--img_huerotate)) saturate(var(--img_saturate)) blur(var(--img_blur));
mix-blend-mode: var(--img_mixblendmode);
}
.photograph.monochrome img,
.tiny_thumbnail.monochrome img
{
filter: sepia(var(--img_sepia)) hue-rotate(var(--img_huerotate)) saturate(var(--img_saturate)) blur(var(--img_blur));
}
.photograph .number_tag
{
position: absolute;
bottom: 8px;
right: 8px;
text-align: center;
background-color: white;
padding: 1px;
color: black;
text-decoration: none;
font-family: sans-serif;
border-radius: 4px;
font-weight: bold;
opacity: 50%;
}
.album .album_tinies
{
max-width: 80em;
text-align: justify;
}
.tiny_thumbnail
{
vertical-align: middle;
display:inline-block;
margin: 8px;
}
.tiny_thumbnail img
{
aspect-ratio: auto;
border-radius: var(--img_borderradius_tiny);
outline: 4px solid var(--color_bodybg);
filter: hue-rotate(var(--img_huerotate)) saturate(var(--img_saturate)) blur(var(--img_blur));
mix-blend-mode: var(--img_mixblendmode);
}
@media not print
{
.photograph img
{
box-shadow: #000 0px 0px 40px -10px;
}
}
@media screen and (min-width: 600px)
{
.tiny_thumbnail img
{
height: 128px;
}
}
@media screen and (max-width: 600px)
{
.tiny_thumbnail img
{
height: 64px;
}
.photograph
{
box-shadow: none;
}
}
@media not all and (pointer: fine)
{
#keyboardhint,
#scrollbartoggle
{
display: none;
}
}
h1 {font-size: 2.00em;} h1 * {font-size: inherit;}
h2 {font-size: 1.75em;} h2 * {font-size: inherit;}
h3 {font-size: 1.50em;} h3 * {font-size: inherit;}
h4 {font-size: 1.25em;} h4 * {font-size: inherit;}
h5 {font-size: 1.00em;} h5 * {font-size: inherit;}
#a_new_perspective
{
background-color: var(--color_bodybg);
position: fixed;
left: 8px;
padding: 8px;
border-radius: 8px;
}
</style> </style>
</head> </head>
@ -178,9 +441,47 @@ def write_directory_index(directory):
{%- endif -%} {%- endif -%}
</header> </header>
{% if not is_root %}
<h1>{{doctitle}}</h1>
{% endif %}
{% for item in items %} {% for item in items %}
{{item.render_web()}} {{item.render_web(index=loop.index, totalcount=none if is_root else (items|length))}}
{% endfor %} {% endfor %}
<footer>
<p>Ethan Dalool</p>
<p>Contact me: photography@voussoir.net</p>
</footer>
<p><button id="new_perspective_button" onclick="return new_perspective_button_onclick(event);">👁 Try a different perspective</button></p>
<form id="a_new_perspective" class="hidden">
<div><label>Background color: <input type="color" value="#1b1c18" oninput="return backgroundcolor_onchange(event);"/></label></div>
<div><label>Border radius: <input type="range" min="0" value="16" max="500" oninput="return border_radius_onchange(event);"/></label> <span id="border_radius_value">16px</span></div>
<div><label>Saturation: <input type="range" min="0" value="100" max="500" oninput="return saturate_onchange(event);"/></label> <span id="saturate_value">100%</span></div>
<div><label>Hue rotate: <input type="range" min="0" value="0" max="360" oninput="return hue_rotate_onchange(event);"/></label> <span id="hue_rotate_value">0deg</span></div>
<div><label>Blur: <input type="range" min="0" value="0" max="50" oninput="return blur_onchange(event);"/></label> <span id="blur_value">0%</span></div>
<div><label>Mix blend mode: <select onchange="return mixblendmode_onchange(event);">
<option selected>normal</option>
<option>multiply</option>
<option>screen</option>
<option>overlay</option>
<option>darken</option>
<option>lighten</option>
<option>color-dodge</option>
<option>color-burn</option>
<option>hard-light</option>
<option>soft-light</option>
<option>difference</option>
<option>exclusion</option>
<option>hue</option>
<option>saturation</option>
<option>color</option>
<option>luminosity</option>
</select></label></div>
</form>
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
@ -212,70 +513,93 @@ def write_directory_index(directory):
} }
} }
function get_center_img() const SCROLL_STOPS = Array.from(document.querySelectorAll("article.photograph img, .album_tinies"));
function get_center_stop()
{ {
let center_x = window.innerWidth / 2; let center_x = window.innerWidth / 2;
let center_y = window.innerHeight / 2; let center_y = window.pageYOffset + (window.innerHeight / 2);
while (true) let final;
for (const stop of SCROLL_STOPS)
{ {
const element = document.elementFromPoint(center_x, center_y); const stop_top = stop.getBoundingClientRect().top + window.pageYOffset;
if (element.tagName === "IMG") const stop_bottom = stop_top + stop.offsetHeight;
console.log("-----");
console.log(stop);
console.log(`${center_y} versus ${stop_top}--${stop_bottom}`);
console.log("-----");
if (center_y > stop_top)
{ {
return element; final = stop;
} }
center_y -= 20; else
if (center_y <= 0)
{ {
return null; break;
} }
} }
if (final)
{
return final;
}
} }
function next_img(img) function next_img(img)
{ {
const images = Array.from(document.images); const this_index = SCROLL_STOPS.indexOf(img);
const this_index = images.indexOf(img); if (this_index === SCROLL_STOPS.length-1)
if (this_index === images.length-1)
{ {
return img; return img;
} }
return images[this_index + 1]; return SCROLL_STOPS[this_index + 1];
} }
function previous_img(img) function previous_img(img)
{ {
const images = Array.from(document.images); const this_index = SCROLL_STOPS.indexOf(img);
const this_index = images.indexOf(img);
if (this_index === 0) if (this_index === 0)
{ {
return img; return img;
} }
return images[this_index - 1]; return SCROLL_STOPS[this_index - 1];
} }
function scroll_step() function scroll_step()
{ {
const distance = desired_scroll_position - document.body.scrollTop; const distance = desired_scroll_position - document.documentElement.scrollTop;
const jump = (distance * 0.25) + (document.body.scrollTop < desired_scroll_position ? 1 : -1); const jump = (distance * 0.25) + (document.documentElement.scrollTop < desired_scroll_position ? 1 : -1);
document.body.scrollTop = document.body.scrollTop + jump; document.documentElement.scrollTop = document.documentElement.scrollTop + jump;
console.log(`${document.body.scrollTop} ${desired_scroll_position}`); //console.log(`${document.documentElement.scrollTop} ${desired_scroll_position}`);
const new_distance = desired_scroll_position - document.body.scrollTop; const new_distance = desired_scroll_position - document.documentElement.scrollTop;
if (Math.abs(new_distance / distance) < 0.97) if (Math.abs(new_distance / distance) < 0.97)
{ {
window.requestAnimationFrame(scroll_step); window.requestAnimationFrame(scroll_step);
} }
} }
function scroll_to_img(img) function scroll_to_stop(stop)
{ {
const img_centerline = img.getBoundingClientRect().top + img.ownerDocument.defaultView.pageYOffset + (img.offsetHeight / 2); if (stop.offsetHeight > window.innerHeight)
// document.body.scrollTop = img_centerline - (window.innerHeight / 2); {
desired_scroll_position = Math.round(img_centerline - (window.innerHeight / 2)); desired_scroll_position = stop.getBoundingClientRect().top + window.pageYOffset;
}
else
{
const img_centerline = stop.getBoundingClientRect().top + window.pageYOffset + (stop.offsetHeight / 2);
// document.documentElement.scrollTop = img_centerline - (window.innerHeight / 2);
desired_scroll_position = Math.round(img_centerline - (window.innerHeight / 2));
}
scroll_step(); scroll_step();
} }
function scroll_to_next_img() function scroll_to_next_img()
{ {
scroll_to_img(next_img(get_center_img())); const current_stop = get_center_stop();
if (current_stop)
{
scroll_to_stop(next_img(current_stop));
}
} }
function scroll_to_previous_img() function scroll_to_previous_img()
{ {
scroll_to_img(previous_img(get_center_img())); const current_stop = get_center_stop();
if (current_stop)
{
scroll_to_stop(previous_img(current_stop));
}
} }
function arrowkey_listener(event) function arrowkey_listener(event)
{ {
@ -304,6 +628,78 @@ def write_directory_index(directory):
clearTimeout(hide_cursor_timeout); clearTimeout(hide_cursor_timeout);
hide_cursor_timeout = setTimeout(hide_cursor, 3000); hide_cursor_timeout = setTimeout(hide_cursor, 3000);
} }
function new_perspective_button_onclick(event)
{
const p = event.target.parentElement;
document.getElementById("a_new_perspective").classList.remove("hidden");
p.parentElement.removeChild(p);
}
function backgroundcolor_onchange(event)
{
document.documentElement.style.setProperty("--color_htmlbg", event.target.value);
}
function border_radius_onchange(event)
{
if (event.target.value == "500")
{
const value = "100%";
document.documentElement.style.setProperty("--img_borderradius", value);
document.documentElement.style.setProperty("--img_borderradius_tiny", value);
document.getElementById("border_radius_value").textContent = value;
}
else
{
const value = event.target.value + "px";
document.documentElement.style.setProperty("--img_borderradius", value);
document.documentElement.style.setProperty("--img_borderradius_tiny", (event.target.value / 4) + "px");
document.getElementById("border_radius_value").textContent = value;
}
}
function saturate_onchange(event)
{
const value = event.target.value + "%";
document.documentElement.style.setProperty("--img_saturate", value);
if (event.target.value == "0")
{
document.getElementById("saturate_value").textContent = "Artistic";
}
else
{
document.getElementById("saturate_value").textContent = value;
}
}
function hue_rotate_onchange(event)
{
const value = event.target.value + "deg";
if (event.target.value === "0")
{
document.documentElement.style.setProperty("--img_sepia", "0");
}
else
{
document.documentElement.style.setProperty("--img_sepia", "100%");
}
document.documentElement.style.setProperty("--img_huerotate", value);
document.getElementById("hue_rotate_value").textContent = value;
}
function blur_onchange(event)
{
const value = event.target.value + "px";
document.documentElement.style.setProperty("--img_blur", value);
document.getElementById("blur_value").textContent = value;
}
function mixblendmode_onchange(event)
{
document.documentElement.style.setProperty("--img_mixblendmode", event.target.value);
}
function on_pageload() function on_pageload()
{ {
document.documentElement.addEventListener("keydown", arrowkey_listener); document.documentElement.addEventListener("keydown", arrowkey_listener);
@ -315,23 +711,20 @@ def write_directory_index(directory):
</script> </script>
</html> </html>
''').render( ''').render(
css_content=CSS_CONTENT, is_root=is_root,
directory=directory, doctitle=doctitle,
rss_link=rss_link, rss_link=rss_link,
back_link=back_link, back_link=back_link,
items=items, items=items,
) )
write(directory.with_child('index.html'), page) return html
if rss_link:
write_atom(items)
def write_atom(items): def write_atom(items):
atom = jinja2.Template(''' atom = jinja2.Template('''
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"> <feed xmlns="http://www.w3.org/2005/Atom">
<title>voussoir.net/photography</title> <title>voussoir.net/photography</title>
<link href="webpath(PHOTOGRAPHY_ROOTDIR)}"/> <link href="https://voussoir.net/photography"/>
<id>voussoir.net/photography</id> <id>voussoir.net/photography</id>
{% for item in items %} {% for item in items %}
@ -343,21 +736,41 @@ def write_atom(items):
'''.strip()).render(items=items) '''.strip()).render(items=items)
write(ATOM_FILE, atom) write(ATOM_FILE, atom)
def make_thumbnail(photo): # write_directory_index(PHOTOGRAPHY_ROOTDIR)
small_name = photo.replace_extension('').basename + '_small' # for directory in PHOTOGRAPHY_ROOTDIR.walk_directories():
small_name = photo.parent.with_child(small_name).add_extension(photo.extension) # write_directory_index(directory)
if small_name.is_file:
return small_name
image = PIL.Image.open(photo.absolute_path)
icc = image.info.get('icc_profile')
(image_width, image_height) = image.size
exif = image.getexif()
(width, height) = imagetools.fit_into_bounds(image_width, image_height, 1440, 1440)
image = image.resize((width, height), PIL.Image.LANCZOS)
image.save(small_name.absolute_path, quality=75, exif=exif, icc_profile=icc)
print(small_name)
return small_name
write_directory_index(PHOTOGRAPHY_ROOTDIR) @vlogging.main_decorator
for directory in PHOTOGRAPHY_ROOTDIR.walk_directories(): def main(argv):
write_directory_index(directory) singlephotos = list(pdb.search(tag_mays=[PUBLISH_TAGNAME], has_albums=False, yield_albums=False, yield_photos=True).results)
singlephotos += list(pdb.search(tag_mays=['voussoir_net_publish_single'], yield_albums=False, yield_photos=True).results)
singlephotos = [Photo(p) for p in singlephotos]
singlephotos.sort(key=lambda i: i.published, reverse=True)
albums = list(pdb.search(tag_musts=[PUBLISH_TAGNAME], tag_forbids=['voussoir_net_publish_single'], has_albums=True, yield_albums=True, yield_photos=False).results)
albums = [Album(a) for a in albums]
albums.sort(key=lambda i: i.published, reverse=True)
items = singlephotos + albums
items.sort(key=lambda i: i.published, reverse=True)
for item in items:
item.prepare()
log.info('Writing homepage')
homepage_html = make_webpage(items, is_root=True, doctitle='photography')
homepage_file = PHOTOGRAPHY_ROOTDIR.with_child('photography.html')
homepage_file.write('w', homepage_html)
for album in albums:
album_html = make_webpage(album.photos, is_root=False, doctitle=album.article_id)
album_file = PHOTOGRAPHY_ROOTDIR.with_child(album.article_id).replace_extension('html')
log.info('Writing %s', album_file.absolute_path)
album_file.write('w', album_html)
write_atom(items)
return 0
if __name__ == '__main__':
raise SystemExit(main(sys.argv[1:]))

View file

@ -203,8 +203,79 @@ pre
--> -->
<article> <article>
<p><b>Reply-To</b>: grant@squadhelp.co</p> <p><b>From</b>: Fiona Hill &lt;bettyraymond201@gmail.com&gt;</p>
<p><b>Reply-To</b>: fionahill142@gmail.com</p>
<p><b>To</b>: fionahill142@gmail.com</p>
<p><b>Bcc</b>: writing@voussoir.net</p>
<p><b>Subject</b>: </p>
<p><b>Date</b>: Fri, 8 Sep 2023 15:29:07 +0000</p>
<details>
<summary>Headers</summary>
<pre>
X-Spam-Status: No, score=-2.44 required=5 tests=DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,FREEMAIL_FROM,FREEMAIL_REPLYTO,FREEMAIL_REPLYTO_END_DIGIT,RCVD_IN_MSPIKE_H2,SPF_HELO_NONE,PURELYMAIL_IP_REPUTATION autolearn=ham autolearn_force=no version=4.0.0
X-Pm-Spam-PURELYMAIL-IP-REPUTATION: -2.64;EffectiveIp=209.85.219.41,SpamProbability=0.02
Return-Path: &lt;bettyraymond201@gmail.com&gt;
Delivered-To: ethan@voussoir.net
X-Pm-Known-Alias: writing@voussoir.net
X-Pm-Original-To: writing@voussoir.net
Authentication-Results: purelymail.com; spf=pass (domain of _spf.google.com designates 209.85.219.41 as permitted sender) smtp.mailfrom=gmail.com; dkim=pass header.i=gmail.com; dmarc=pass (p=none) header.from=bettyraymond201@gmail.com
Received: from mail-qv1-f41.google.com (EHLO mail-qv1-f41.google.com) ([209.85.219.41])
by smtp.purelymail.com (Purelymail SMTP) with ESMTPS id -114360871
for &lt;writing@voussoir.net&gt;
(version=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384);
Fri, 08 Sep 2023 15:29:08 +0000 (UTC)
Received: by mail-qv1-f41.google.com with SMTP id 6a1803df08f44-64f3ad95ec0so13119666d6.1
for &lt;writing@voussoir.net&gt;; Fri, 08 Sep 2023 08:29:08 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20221208; t=1694186948; x=1694791748; darn=voussoir.net;
h=to:subject:message-id:date:from:reply-to:mime-version:from:to:cc
:subject:date:message-id:reply-to;
bh=Ba3gj8+xBPQLJTahTfzW6RbWQ/XPgESxkCi2B66PSQg=;
b=YasTMC07fWjdPrJEgfH9/EbuJwftHxPKdoeKPhwuG0DlU3lg1Zo/j8NtchSnEt1Gdp
5OqyuPcnmVW3BZodZUZsR6R1lmlmAnbt/2lsDUrHGednKwfjwmxjekCWURhU4cxPatDl
DkQxA/su7gAHB2Gzb+E1qiWwqOmm7Lc/ettNaamilMQUHQ0MKdgCMQd9kDy49km6mY/1
R6Nvap5kc1rY46b02VxitvacfSiRqR3BSyWqJSSUvtjXc1o/WcfSA50YXW2xOjlQgEG9
t/ckwsDY7uHYmdRHUaZGokiEvdD5O0f53Rp6byCxJublcIa180ShWngjPZWwFslltr0I
ex1g==
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1694186948; x=1694791748;
h=to:subject:message-id:date:from:reply-to:mime-version
:x-gm-message-state:from:to:cc:subject:date:message-id:reply-to;
bh=Ba3gj8+xBPQLJTahTfzW6RbWQ/XPgESxkCi2B66PSQg=;
b=CBxDaKVaxeHutPqbgxvlqmwwoZ0jsMgDFsBmr8zgYCF+wiA1IbaioYudVPkENjzNNo
0t2H0B9rRo2KmaBzwxDX2pt6dvtpCJ80M1lN3eFlpSmbZqigglv7AfyVEsFtfaLwAPxv
MurPpOErCWMoWYNhI5TAYUhaWs95WLQaEn125TJFWmufhEnVoEd2U3RMnACcn+B7dNsT
FUuise8xuOJSJhA+MYxrQnchDwDkCIkruhkErMt9/IWjnZ6JIeKTdtVT/2NkGOEo50o5
CBIp2fbgi8BWchGuq9rxUxtXteVuJhpsOgXtLJu9UfRj2RGgNhtl+9IDHNSF1hfTysqo
Oh/w==
X-Gm-Message-State: AOJu0YyInMyJvkYc6g+p66NCNSu/cKD11y6pidrfNcAMnIBlTT50oy0v
Ud1GUYOI+8wMUMpHNZ/7nzUAgs2vZhLU9MiwodY=
X-Google-Smtp-Source: AGHT+IEZJmW6LWDaz7ky7Vtqc9VL15HnDlWpbje8Ob/RkQ+ioqTvE3WaJRy4/QkqQfqQjVn/BbhhjU/DI2QearkIFHA=
X-Received: by 2002:a0c:d610:0:b0:647:1a66:181a with SMTP id
c16-20020a0cd610000000b006471a66181amr2420913qvj.50.1694186947840; Fri, 08
Sep 2023 08:29:07 -0700 (PDT)
MIME-Version: 1.0
Received: by 2002:a0c:ab5e:0:b0:64d:3e37:9bff with HTTP; Fri, 8 Sep 2023
08:29:07 -0700 (PDT)
Reply-To: fionahill142@gmail.com
From: Fiona Hill &lt;bettyraymond201@gmail.com&gt;
Date: Fri, 8 Sep 2023 15:29:07 +0000
Message-ID: &lt;CALZothvq=+PPgJAZxrSNGZa1hx4DcnMTYqxg4ZiEwuntu1TKLA@mail.gmail.com&gt;
Subject:
To: fionahill142@gmail.com
Content-Type: text/plain; charset=&quot;UTF-8&quot;
Bcc: writing@voussoir.net
</pre>
</details>
<hr/>
<p>Hello</p>
</article>
<article>
<p><b>From</b>: Grant Polachek &lt;pola-grant@squadhelp.co&gt;</p> <p><b>From</b>: Grant Polachek &lt;pola-grant@squadhelp.co&gt;</p>
<p><b>Reply-To</b>: grant@squadhelp.co</p>
<p><b>To</b>: contact@voussoir.net</p> <p><b>To</b>: contact@voussoir.net</p>
<p><b>Subject</b>: Re: Want an exclusive guest article for voussoir.net?</p> <p><b>Subject</b>: Re: Want an exclusive guest article for voussoir.net?</p>
<p><b>Date</b>: Tue, 05 Sep 2023 11:59:33 +0000</p> <p><b>Date</b>: Tue, 05 Sep 2023 11:59:33 +0000</p>

View file

@ -525,7 +525,7 @@ Spam is built upon the abuse of systems by definition. Unlike sponsors, who make
2. are highly trafficked, because spammers need to hit as many people as possible before getting caught. 2. are highly trafficked, because spammers need to hit as many people as possible before getting caught.
3. provide otherwise legitimate value to your life, so that you will not simply leave the space after the spammers invade. 3. provide otherwise legitimate value to your life, so that you will not simply leave the space after the spammers invade.
Your email inbox and your telephone are spaces for communicating with people you know, and spammers abuse it to send you junk. Web forums are spaces for conversation, and spammers abuse them by making fake posts. Grocery stores are spaces for buying food, and spammers abuse the traffic flow to hand out fliers. [Parking lots](/writing/not_just_bikes) are part of our transit system, and spammers leave advertisements under your wipers because you're not there to stop them. The front door of your house is for yourself and your guests, and spammers abuse it by turning it into ad space. Your [email inbox](/spam) and your telephone are spaces for communicating with people you know, and spammers abuse it to send you junk. Web forums are spaces for conversation, and spammers abuse them by making fake posts. Grocery stores are spaces for buying food, and spammers abuse the traffic flow to hand out fliers. [Parking lots](/writing/not_just_bikes) are part of our transit system, and spammers leave advertisements under your wipers because you're not there to stop them. The front door of your house is for yourself and your guests, and spammers abuse it by turning it into ad space.
The problem with spam is not just that it's annoying, but that it converts your entire life into an arms race. Spam is hostile. It is mean. Spam takes every aspect of your life and turns it into an opportunity to make a buck off of you. No matter how hard you work to avoid advertisements and carefully pick the publishers you want to pay for, spammers can override your decisions and advertise to you anyway. I am not kidding when I compare spam to terrorism, though I am exaggerating. Spam positions you against enemies you didn't know you had. Enemies who come from afar to your place of residence to disrespect your time, attention, and belongings, and sneak away. Unlike a TV which can be turned off or a magazine which can be closed, spam offers no means to opt-out. Spammers will hit you and run without showing their face. What are you going to do about it, not have an email address? Not have a phone number? The problem with spam is not just that it's annoying, but that it converts your entire life into an arms race. Spam is hostile. It is mean. Spam takes every aspect of your life and turns it into an opportunity to make a buck off of you. No matter how hard you work to avoid advertisements and carefully pick the publishers you want to pay for, spammers can override your decisions and advertise to you anyway. I am not kidding when I compare spam to terrorism, though I am exaggerating. Spam positions you against enemies you didn't know you had. Enemies who come from afar to your place of residence to disrespect your time, attention, and belongings, and sneak away. Unlike a TV which can be turned off or a magazine which can be closed, spam offers no means to opt-out. Spammers will hit you and run without showing their face. What are you going to do about it, not have an email address? Not have a phone number?

View file

@ -3,6 +3,20 @@ Cyborgs on HN
This page collects comments which make unnecessary or tenuous analogies to computers, programming, dollar-sign $variables, sed's/replace/syntax/g, mathematics, AI/machine learning, and cryptography in discussions that aren't about those things. This page collects comments which make unnecessary or tenuous analogies to computers, programming, dollar-sign $variables, sed's/replace/syntax/g, mathematics, AI/machine learning, and cryptography in discussions that aren't about those things.
> If you search "watch $MOVIE free" on google you're going to get netflix, Hulu, prime, Disney etc as the first results regardless of whether those sites even have it in their library.
-
> I hope these journalists are ashamed of themselves and I hope they are at least sacked, with their careers relegated to selling poutine all day.
> > s/selling/eating/ ?
-
> There is always plenty wrong with each new release but the comments and jokes about ${"LATEST_RELEASE"} of ${"SOFTWARE"} being unilaterally bad just make me think the person can't deal with change instead of the software actually being bad.
-
> I'm scratching my head here. The old gas/diesel dispensing stations have solved this problem of restricting people taking all of the fuel in the pump with a disruptive financial technology called blo\^H\^H\^H credit card. > I'm scratching my head here. The old gas/diesel dispensing stations have solved this problem of restricting people taking all of the fuel in the pump with a disruptive financial technology called blo\^H\^H\^H credit card.
- -

View file

@ -162,6 +162,11 @@ table th, table td
padding: 4px; padding: 4px;
} }
hr
{
border-color: var(--color_codeborder);
}
ol ol, ul ul, ol ul, ul ol ol ol, ul ul, ol ul, ul ol
{ {
padding-inline-start: 20px; padding-inline-start: 20px;

View file

@ -145,6 +145,10 @@ You're overthinking it!
Feedback wanted. Feedback wanted.
---
**Update**: I have not received any feedback, and I've taken some more good pictures. [Scales tipped](https://voussoir.net/photography).
![](photo1.jpg) ![](photo1.jpg)
![](photo2.jpg) ![](photo2.jpg)