Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fbd2c1fbd | |||
| daf8282177 | |||
| 01976ebf6c | |||
| a77af8b50b | |||
| c430ee4a5d | |||
| 4d593595b6 | |||
| 49557153a6 | |||
| b2e148e252 | |||
| df9f42decd | |||
| f68b1c022b |
@@ -1,9 +0,0 @@
|
||||
AWS_ACCESS_KEY_ID=**************************
|
||||
AWS_SECRET_ACCESS_KEY=****************************************************************
|
||||
AWS_DEFAULT_REGION=******
|
||||
AWS_ENDPOINT_URL=http://localhost:3900
|
||||
DATABASE_URL=file:./libsql.db
|
||||
S3_BUCKET="comicverse-pre-alpha"
|
||||
# Keys should be encoded in base64url
|
||||
PRIVATE_KEY=*******************************
|
||||
PUBLIC_KEY=*******************************
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:opf="http://www.idpf.org/2007/opf" unique-identifier="unique-identifier" version="3.0">
|
||||
<metadata>
|
||||
<dc:identifier id="unique-identifier">2b982cb2-7144-4aa2-aa86-f9f6ba47fa0d</dc:identifier>
|
||||
<dc:title>Unknown Title</dc:title>
|
||||
<dc:creator>Unknown Author</dc:creator>
|
||||
<dc:language>en</dc:language>
|
||||
<meta property="dcterms:modified">2025-07-31T15:14:16Z</meta>
|
||||
<meta property="rendition:layout">pre-paginated</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item href="images/image0001.png" id="image0001" media-type="image/png"/>
|
||||
<item href="images/image0002.png" id="image0002" media-type="image/png"/>
|
||||
<item href="images/image0003.png" id="image0003" media-type="image/png"/>
|
||||
<item href="images/image0004.png" id="image0004" media-type="image/png"/>
|
||||
<item href="audios/audio0001.wav" id="audio0001" media-type="audio/wav"/>
|
||||
<item href="styles/stylesheet.css" id="stylesheet.css" media-type="text/css"/>
|
||||
<item href="scripts/ipub.js" id="ipub.js" media-type="application/javascript"/>
|
||||
<item href="toc.ncx" id="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item href="toc.xhtml" id="toc.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||
<item href="sections/section0001.xhtml" id="section0001" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="toc.ncx">
|
||||
<itemref idref="section0001"/>
|
||||
</spine>
|
||||
</package>
|
||||
@@ -1,882 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
class IPUBElement extends HTMLElement {
|
||||
/**
|
||||
* @protected
|
||||
* @type {Readonly<string[]>}
|
||||
*/
|
||||
static observedAttributes = ["id", "debug"];
|
||||
|
||||
connectedCallback() {
|
||||
this.#ensureID();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, _oldValue, _newValue) {
|
||||
switch (name) {
|
||||
case "id":
|
||||
this.#ensureID();
|
||||
break;
|
||||
case "debug":
|
||||
if (this.hasAttribute("debug")) {
|
||||
this.setDebug(true);
|
||||
} else {
|
||||
this.setDebug(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
#ensureID() {
|
||||
if (!this.id) {
|
||||
this.id = hashFromHTML(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getDebug() {
|
||||
return this.hasAttribute("debug");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} state
|
||||
*/
|
||||
setDebug(state) {
|
||||
if (state) {
|
||||
if (!this.hasAttribute("debug")) {
|
||||
this.setAttribute("debug", "true");
|
||||
}
|
||||
|
||||
if (!this.style.getPropertyValue(IPUBElement.#PROPERTY_DEBUG_COLOR)) {
|
||||
this.style.setProperty(
|
||||
IPUBElement.#PROPERTY_DEBUG_COLOR,
|
||||
`#${hashFromHTML(this)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!state && this.hasAttribute("debug")) {
|
||||
this.removeAttribute("debug");
|
||||
}
|
||||
|
||||
if (
|
||||
!state &&
|
||||
this.style.getPropertyValue(IPUBElement.#PROPERTY_DEBUG_COLOR)
|
||||
) {
|
||||
this.style.removeProperty(IPUBElement.#PROPERTY_DEBUG_COLOR);
|
||||
}
|
||||
}
|
||||
|
||||
getAllDescendants(this)
|
||||
.filter((el) => el.tagName.startsWith("ipub-"))
|
||||
.forEach((el) => {
|
||||
el.setDebug?.(state);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {Readonly<string>}
|
||||
*/
|
||||
static #PROPERTY_DEBUG_COLOR = "--ipub-debug-color";
|
||||
}
|
||||
|
||||
globalThis.addEventListener("load", () => {
|
||||
console.info("IPUB: Starting IPUB elements");
|
||||
|
||||
console.log("IPUB: Defining custom element <ipub-body>");
|
||||
globalThis.customElements.define(IPUBBody.elementName, IPUBBody);
|
||||
});
|
||||
|
||||
class IPUBBody extends IPUBElement {
|
||||
static elementName = `ipub-body`;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.setAttribute("aria-busy", "true");
|
||||
|
||||
// TODO?: Move IPUBCover's "can-play" logic to here
|
||||
|
||||
console.log("IPUBBody: Defining custom element <ipub-cover>");
|
||||
globalThis.customElements.define(IPUBCover.elementName, IPUBCover);
|
||||
|
||||
/** @type {IPUBCover} */
|
||||
const cover = this.querySelector("ipub-cover");
|
||||
if (!cover) {
|
||||
// TODO: automatically create IPUBCover element if it doesn't exists
|
||||
console.error("IPUBBody: Document doesn't has <ipub-cover> element");
|
||||
this.#initElements();
|
||||
this.setAttribute("aria-busy", "false");
|
||||
return;
|
||||
}
|
||||
|
||||
cover.onclose = () => {
|
||||
this.#initElements();
|
||||
};
|
||||
cover.cover();
|
||||
|
||||
this.setAttribute("aria-busy", "false");
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
#initElements() {
|
||||
for (const e of [
|
||||
IPUBAudio,
|
||||
IPUBBackground,
|
||||
IPUBImage,
|
||||
IPUBInteraction,
|
||||
IPUBSoundtrack,
|
||||
IPUBTrigger,
|
||||
]) {
|
||||
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
|
||||
globalThis.customElements.define(e.elementName, e);
|
||||
}
|
||||
|
||||
if (this.getDebug()) {
|
||||
// HACK: Re-trigger IPUBElement debugging logic
|
||||
console.debug("IPUBBody: triggeing debugger");
|
||||
this.setAttribute("debug", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IPUBCover extends IPUBElement {
|
||||
static elementName = `ipub-cover`;
|
||||
|
||||
/**
|
||||
* @type {() => void} callback
|
||||
*/
|
||||
onclose = () => {};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
cover() {
|
||||
console.debug("IPUBCover: Setting up cover");
|
||||
this.setAttribute("aria-busy", "true");
|
||||
|
||||
const dialog = this.querySelector("dialog");
|
||||
|
||||
// HACK: Test if we can autoplay interactions, soundtracks, etc
|
||||
|
||||
/** @type {HTMLMediaElement | null} */
|
||||
const media =
|
||||
this.parentElement.querySelector("audio") ??
|
||||
this.parentElement.querySelector("video");
|
||||
|
||||
if (!media) {
|
||||
console.log("IPUBCover: no media element found, removing cover");
|
||||
dialog.close();
|
||||
this.onclose();
|
||||
return;
|
||||
}
|
||||
|
||||
const pastVolume = media.volume;
|
||||
media.volume = 0.1; // don't let the user hear the test audio
|
||||
|
||||
media
|
||||
.play()
|
||||
.then(() => {
|
||||
media.pause();
|
||||
media.volume = pastVolume;
|
||||
media.currentTime = 0;
|
||||
|
||||
console.debug("IPUBCover: Can autoplay interactions, removing cover");
|
||||
dialog.close();
|
||||
this.onclose();
|
||||
})
|
||||
.catch(() => {
|
||||
console.debug(
|
||||
"IPUBCover: Cannot autoplay interactions, covering content",
|
||||
);
|
||||
|
||||
dialog.show();
|
||||
|
||||
dialog.parentElement.addEventListener("click", () => {
|
||||
dialog.close();
|
||||
this.onclose();
|
||||
});
|
||||
|
||||
this.setAttribute("aria-busy", "false");
|
||||
return;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class IPUBAudio extends IPUBElement {
|
||||
static elementName = "ipub-audio";
|
||||
|
||||
/**
|
||||
* @param {boolean} [forced=false]
|
||||
* @throws {Error}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
play(forced = false) {
|
||||
if (!this.#audioElement.readyState > HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
throw new Error("IPUBAudio: audio is not ready");
|
||||
}
|
||||
if (forced) {
|
||||
this.setAttribute("forced", "true");
|
||||
}
|
||||
return this.#audioElement.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} [forced=false]
|
||||
*/
|
||||
pause(forced = false) {
|
||||
if (forced) {
|
||||
this.setAttribute("forced", "true");
|
||||
}
|
||||
this.#audioElement.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} state
|
||||
*/
|
||||
setLoop(state) {
|
||||
this.#audioElement.loop = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getLoop() {
|
||||
return this.#audioElement.loop;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const audio = this.querySelector("audio");
|
||||
if (!audio) {
|
||||
console.error("IPUBAudio: Missing child <audio> element");
|
||||
return;
|
||||
}
|
||||
|
||||
this.#audioElement = audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {HTMLAudioElement}
|
||||
*/
|
||||
#audioElement;
|
||||
|
||||
/**
|
||||
* @param {number} volume
|
||||
* @param {Object} options
|
||||
* @param {number} [options.fadetime=0]
|
||||
* @param {() => void} [options.onfinish=() => {}]
|
||||
*/
|
||||
setVolume(volume, { fadetimeMS = 0, onFinishFade = () => {} } = {}) {
|
||||
if (fadetimeMS === 0) {
|
||||
this.#audioElement.volume = volume / 100;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#isFading) {
|
||||
// TODO: Be able to force fading to be canceled
|
||||
return;
|
||||
}
|
||||
|
||||
this.#onFinishFade = onFinishFade;
|
||||
|
||||
const diff = volume - this.#audioElement.volume * 100;
|
||||
const ticks = diff < 0 ? Math.abs(diff) : diff;
|
||||
let tick = 0;
|
||||
|
||||
const interval = fadetimeMS / ticks;
|
||||
|
||||
this.#isFading = true;
|
||||
this.#fadeTask = setInterval(() => {
|
||||
tick++;
|
||||
|
||||
const cancel = () => {
|
||||
this.#isFading = false;
|
||||
if (onFinishFade) {
|
||||
onFinishFade();
|
||||
}
|
||||
clearInterval(this.#fadeTask);
|
||||
this.#onFinishFade = null;
|
||||
};
|
||||
|
||||
if (!this.#audioElement) {
|
||||
cancel();
|
||||
console.error("IPUBAudio: Missing child <audio> element");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticks < tick) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (volume === this.getVolume()) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (diff === 0) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const nvol =
|
||||
(diff > 0
|
||||
? Math.ceil(this.#audioElement.volume * 100 + 1)
|
||||
: Math.floor(this.#audioElement.volume * 100 - 1)) / 100;
|
||||
|
||||
if (nvol > 1 || nvol < 0) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
this.#audioElement.volume = nvol;
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getVolume() {
|
||||
return Math.floor((this.#audioElement?.volume ?? 0) * 100);
|
||||
}
|
||||
|
||||
#isFading = false;
|
||||
#fadeTask = 0;
|
||||
/** @type {() => void | null} */
|
||||
#onFinishFade = null;
|
||||
}
|
||||
|
||||
class IPUBBackground extends IPUBElement {
|
||||
static elementName = "ipub-background";
|
||||
static observedAttributes = ["nofade"].concat(super.observedAttributes);
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
static #instancesOnScreen = {
|
||||
/** @type {Map<IPUBBody, Set<IPUBBackground>>} */
|
||||
map: new Map(),
|
||||
/**
|
||||
* @param {IPUBBackground} background
|
||||
*/
|
||||
add(background) {
|
||||
const body = getAncestor(background, IPUBBody.elementName);
|
||||
if (!body) {
|
||||
console.error(
|
||||
`IPUBBackground: ${background.id} does not have a valid ipub-body ancestor`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let set = this.map.get(body);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
body.addEventListener("scroll", () => {
|
||||
for (const instance of set.values()) {
|
||||
const perc = getPercentageInView(
|
||||
instance.querySelector("img") || instance,
|
||||
);
|
||||
instance.fade(perc);
|
||||
}
|
||||
});
|
||||
}
|
||||
set.add(background);
|
||||
this.map.set(body, set);
|
||||
},
|
||||
/**
|
||||
* @param {IPUBBackground} background
|
||||
*/
|
||||
remove(background) {
|
||||
const body = getAncestor(background, IPUBBody.elementName);
|
||||
if (!body) {
|
||||
console.error(
|
||||
`IPUBBackground: ${background.id} does not have a valid ipub-body ancestor`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const set = this.map.get(body);
|
||||
if (!set) {
|
||||
return;
|
||||
}
|
||||
|
||||
set.delete(background);
|
||||
|
||||
if (set.size === 0) {
|
||||
this.map.delete(body);
|
||||
}
|
||||
},
|
||||
};
|
||||
static addToScreen() {}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
static #observer = new IntersectionObserver((entries) => {
|
||||
for (const { intersectionRatio, target: image } of entries) {
|
||||
const instance = getAncestor(image, IPUBBackground.elementName);
|
||||
|
||||
if (!instance) {
|
||||
console.error(
|
||||
"IPUBBackground: malformed <ipub-background> element",
|
||||
image,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (intersectionRatio > 0 && instance.id) {
|
||||
IPUBBackground.#instancesOnScreen.add(instance);
|
||||
} else if (instance.id) {
|
||||
IPUBBackground.#instancesOnScreen.remove(instance);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const image = this.querySelector("img");
|
||||
if (!image) {
|
||||
console.error("IPUBBackground: missing <img> element inside background");
|
||||
return;
|
||||
}
|
||||
|
||||
// INFO: We don't need to fade the first background
|
||||
if (this.matches(":first-of-type") || this.hasAttribute("nofade")) {
|
||||
IPUBBackground.#observer.unobserve(image);
|
||||
return;
|
||||
}
|
||||
|
||||
IPUBBackground.#observer.observe(image);
|
||||
const perc = getPercentageInView(image);
|
||||
if (perc > 0) {
|
||||
this.fade(perc);
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, _oldValue, _newValue) {
|
||||
super.attributeChangedCallback();
|
||||
|
||||
if (name !== "nofade") {
|
||||
return;
|
||||
}
|
||||
|
||||
const image = this.querySelector("img");
|
||||
if (!image) {
|
||||
console.error("IPUBBackground: missing <img> element inside background");
|
||||
return;
|
||||
}
|
||||
|
||||
// INFO: We don't need to fade the first background
|
||||
if (this.matches(":first-of-type") || this.hasAttribute("nofade")) {
|
||||
IPUBBackground.#observer.unobserve(image);
|
||||
return;
|
||||
}
|
||||
|
||||
IPUBBackground.#observer.observe(image);
|
||||
const perc = getPercentageInView(image);
|
||||
if (perc > 0) {
|
||||
this.fade(perc);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} perc
|
||||
* @throws {Error}
|
||||
* @returns {void | Promise<void>}
|
||||
*/
|
||||
fade(perc) {
|
||||
if (!this.style.getPropertyValue("--ipub-fade")) {
|
||||
this.style.setProperty("--ipub-fade", `${perc}%`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPerc = this.style.getPropertyValue("--ipub-fade");
|
||||
if (currentPerc === "100%" && perc >= 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (perc % 10 === 0) {
|
||||
this.style.setProperty("--ipub-fade", `${perc}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
class IPUBImage extends IPUBElement {
|
||||
static elementName = "ipub-image";
|
||||
}
|
||||
|
||||
class IPUBInteraction extends IPUBElement {
|
||||
static elementName = "ipub-interaction";
|
||||
}
|
||||
|
||||
class IPUBSoundtrack extends IPUBElement {
|
||||
static elementName = "ipub-soundtrack";
|
||||
|
||||
// TODO: Toggle automatic soundtrack playing
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
static #player = setInterval(() => {
|
||||
const last = Array.from(this.#onScreenStack).pop();
|
||||
if (!last) {
|
||||
// TODO: Fallback to previous soundtrack if there's no audio
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Get siblings based by group OR parent
|
||||
/** @type {NodeListOf<IPUBSoundtrack> | undefined} */
|
||||
const siblings = last.parentElement?.querySelectorAll(
|
||||
IPUBSoundtrack.elementName,
|
||||
);
|
||||
|
||||
try {
|
||||
if (siblings) {
|
||||
siblings.forEach((el) => {
|
||||
if (el !== last) {
|
||||
el.fadeOut();
|
||||
}
|
||||
});
|
||||
}
|
||||
last.fadeIn();
|
||||
} catch (e) {
|
||||
// TODO: Fallback to previous soundtrack on error
|
||||
console.error(
|
||||
`IPUBSoundtrack: error while trying to play audio, error: ${e}`,
|
||||
{
|
||||
error: e,
|
||||
audio: last,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
static #observer = (() => {
|
||||
return new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const { intersectionRatio, target, time } of entries) {
|
||||
/** @type {IPUBSoundtrack} */
|
||||
const soundtrack =
|
||||
target.tagName === IPUBTrigger.elementName
|
||||
? getAncestor(target, IPUBSoundtrack.elementName)
|
||||
: target;
|
||||
|
||||
if (intersectionRatio === 1) {
|
||||
console.debug(
|
||||
`${soundtrack.id} is on screen at ${time}`,
|
||||
soundtrack,
|
||||
);
|
||||
this.#onScreenStack.add(soundtrack);
|
||||
} else {
|
||||
console.debug(
|
||||
`${soundtrack.id} is not on screen ${time}`,
|
||||
soundtrack,
|
||||
);
|
||||
this.#onScreenStack.delete(soundtrack);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 1 },
|
||||
);
|
||||
})();
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {Set<IPUBSoundtrack>}
|
||||
*/
|
||||
static #onScreenStack = new Set();
|
||||
|
||||
/**
|
||||
* @throws {Error}
|
||||
*/
|
||||
fadeIn() {
|
||||
/** @type {IPUBAudio | undefined} */
|
||||
const audio = this.querySelector(IPUBAudio.elementName);
|
||||
if (!audio) {
|
||||
throw new Error("IPUBSoundtrack.fadeIn: missing audio element");
|
||||
}
|
||||
|
||||
// TODO: Global volume settings
|
||||
|
||||
audio.play();
|
||||
audio.setVolume(10, { fadetimeMS: IPUBSoundtrack.FADE_TIME_MS });
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {Error}
|
||||
*/
|
||||
fadeOut() {
|
||||
/** @type {IPUBAudio | undefined} */
|
||||
const audio = this.querySelector(IPUBAudio.elementName);
|
||||
if (!audio) {
|
||||
throw new Error("IPUBSoundtrack.fadeIn: missing audio element");
|
||||
}
|
||||
|
||||
audio.setVolume(0, {
|
||||
fadetimeMS: IPUBSoundtrack.FADE_TIME_MS,
|
||||
onFinishFade: () => {
|
||||
audio.pause();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {Readonly<number>} */
|
||||
static FADE_TIME_MS = 1000 * 3;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
/** @type {IPUBAudio | undefined} */
|
||||
const audio = this.querySelector(IPUBAudio.elementName);
|
||||
if (audio) {
|
||||
audio.setVolume(0);
|
||||
} else {
|
||||
console.error("IPUBSoundtrack: missing audio element");
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = this.querySelector(IPUBTrigger.elementName);
|
||||
if (trigger) {
|
||||
IPUBSoundtrack.#observer.observe(trigger);
|
||||
} else {
|
||||
IPUBSoundtrack.#observer.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(guz013): Handle if element is moved, it's group should be updated
|
||||
// TODO(guz013): Handle if element is deleted/disconnected, it should be removed from observer
|
||||
}
|
||||
|
||||
class IPUBTrigger extends IPUBElement {
|
||||
static elementName = "ipub-trigger";
|
||||
static observedAttributes = ["height", "width"].concat(
|
||||
IPUBElement.observedAttributes,
|
||||
);
|
||||
|
||||
// TODO: Make this observer global
|
||||
/** @private */
|
||||
static #resizeObserver = new ResizeObserver((bodies) => {
|
||||
for (const { target: body, contentRect } of bodies) {
|
||||
const height = Math.max(body.scrollHeight, contentRect.height);
|
||||
const width = Math.max(body.scrollWidth, contentRect.width);
|
||||
|
||||
for (const trigger of IPUBTrigger.#resizableTriggers.get(body)) {
|
||||
const percH = trigger.getAttribute("height");
|
||||
if (percH) {
|
||||
trigger.style.setProperty(
|
||||
"--ipub-height",
|
||||
`${Math.round((height / 100) * Number.parseFloat(percH))}px`,
|
||||
);
|
||||
}
|
||||
|
||||
const percW = trigger.getAttribute("width");
|
||||
if (percW) {
|
||||
trigger.style.setProperty(
|
||||
"--ipub-width",
|
||||
`${Math.round((width / 100) * Number.parseFloat(percW))}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
/**
|
||||
* @private
|
||||
* @type {Map<IPUBBody, Set<IPUBTrigger>>}
|
||||
*/
|
||||
static #resizableTriggers = new Map();
|
||||
|
||||
// FIXME: trigger can be the same size as viewport, cap it to 80% of viewport
|
||||
// height and 100% of viewport width
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const body = getAncestor(this, "ipub-body");
|
||||
if (!body) {
|
||||
console.error("IPUBTrigger: element must be a descendant of ipub-body");
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`IPUBTrigger#${this.id}: adding ${IPUBBody.elementName}#${body.id} from resize observer`,
|
||||
);
|
||||
IPUBTrigger.#resizeObserver.observe(body);
|
||||
if (this.getAttribute("height") || this.getAttribute("width")) {
|
||||
IPUBTrigger.#resizableTriggers.set(
|
||||
body,
|
||||
(IPUBTrigger.#resizableTriggers.get(body) || new Set()).add(this),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
super.attributeChangedCallback(name, oldValue, newValue);
|
||||
|
||||
const body = getAncestor(this, "ipub-body");
|
||||
if (!body) {
|
||||
console.error("IPUBTrigger: element must be a descendant of ipub-body");
|
||||
return;
|
||||
}
|
||||
|
||||
const set = IPUBTrigger.#resizableTriggers.get(body) || new Set();
|
||||
if (this.getAttribute("height") || this.getAttribute("width")) {
|
||||
set.add(this);
|
||||
} else {
|
||||
set.delete(this);
|
||||
}
|
||||
|
||||
if (name === "width" || name === "height") {
|
||||
const height = Math.max(
|
||||
body.scrollHeight,
|
||||
body.getBoundingClientRect().height,
|
||||
);
|
||||
const width = Math.max(
|
||||
body.scrollWidth,
|
||||
body.getBoundingClientRect().width,
|
||||
);
|
||||
|
||||
const percH = this.getAttribute("height");
|
||||
if (percH) {
|
||||
this.style.setProperty(
|
||||
"--ipub-height",
|
||||
`${Math.round((height / 100) * Number.parseFloat(percH))}px`,
|
||||
);
|
||||
}
|
||||
|
||||
const percW = this.getAttribute("width");
|
||||
if (percW) {
|
||||
this.style.setProperty(
|
||||
"--ipub-width",
|
||||
`${Math.round((width / 100) * Number.parseFloat(percW))}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
const set = IPUBTrigger.#resizableTriggers.get(body) || new Set();
|
||||
set.delete(this);
|
||||
|
||||
if (set.size === 0) {
|
||||
const body = getAncestor(this, "ipub-body");
|
||||
if (!body) {
|
||||
console.error("IPUBTrigger: element must be a descendant of ipub-body");
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`IPUBTrigger#${this.id}: removing ${IPUBBody.elementName}#${body.id} from resize observer`,
|
||||
);
|
||||
IPUBTrigger.#resizableTriggers.delete(body);
|
||||
IPUBTrigger.#resizeObserver.unobserve(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
* @param {string} tagName
|
||||
* @returns {HTMLElement | undefined}
|
||||
*/
|
||||
function getAncestor(el, tagName) {
|
||||
if (!el.parentElement) {
|
||||
return undefined;
|
||||
}
|
||||
if (el.parentElement.tagName.toLowerCase() === tagName.toLowerCase()) {
|
||||
return el.parentElement;
|
||||
}
|
||||
return getAncestor(el.parentElement, tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @returns {Element[]}
|
||||
*/
|
||||
function getAllDescendants(el) {
|
||||
return Array.from(el.children).reduce(
|
||||
(acc, current) => {
|
||||
acc.push(current);
|
||||
return acc.concat(getAllDescendants(current));
|
||||
},
|
||||
/** @type {Element[]} */ [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Element>} el
|
||||
* @param {number} [length=6]
|
||||
* @returns {string}
|
||||
*
|
||||
* @copyright This function contains code from a hash algorithm by
|
||||
* {@link https://stackoverflow.com/a/16348977|Joe Freeman} and
|
||||
* {@link https://stackoverflow.com/a/3426956|Cristian Sanchez} at
|
||||
* StackOverflow, licensed under {@link https://creativecommons.org/licenses/by-sa/3.0/|CC BY-SA 3.0}.
|
||||
*/
|
||||
function hashFromHTML(el, length = 6) {
|
||||
const hexLength = length / 2;
|
||||
if (hexLength % 2 !== 0) {
|
||||
hexLength + 1;
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (const char of el.innerHTML) {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let hex = "";
|
||||
for (let i = 0; i < hexLength; i++) {
|
||||
hex += ((hash >> (i * 8)) & 0xff).toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return hex.substring(0, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {number}
|
||||
*/
|
||||
function getPercentageInView(element) {
|
||||
const viewTop = globalThis.pageYOffset;
|
||||
const viewBottom = viewTop + globalThis.innerHeight;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const elementTop = rect.y + viewTop;
|
||||
const elementBottom = rect.y + rect.height + viewTop;
|
||||
|
||||
if (viewTop > elementBottom || viewBottom < elementTop) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (
|
||||
(viewTop < elementTop && viewBottom > elementBottom) ||
|
||||
(elementTop < viewTop && elementBottom > viewBottom)
|
||||
) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
let inView = rect.height;
|
||||
|
||||
if (elementTop < viewTop) {
|
||||
inView = rect.height - (globalThis.pageYOffset - elementTop);
|
||||
}
|
||||
|
||||
if (elementBottom > viewBottom) {
|
||||
inView = inView - (elementBottom - viewBottom);
|
||||
}
|
||||
|
||||
return Math.round((inView / globalThis.innerHeight) * 100);
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta name="x-ipub-version" content="0.1" />
|
||||
<meta name="viewport"
|
||||
content="initial-scale=1,width=device-width,height=device-height,viewport-fit=contain" />
|
||||
<link href="../styles/stylesheet.css" rel="stylesheet" type="text/css" />
|
||||
<!-- <script type="module" src="../scripts/ipub.js" fetchpriority="high"></script> -->
|
||||
<script defer="true" src="../scripts/ipub.js" fetchpriority="high">
|
||||
<!---->
|
||||
</script>
|
||||
</head>
|
||||
<body xmlns:epub="http://www.idpf.org/2007/ops" class="body">
|
||||
<ipub-body style="--ipub-padding: 10%;" debug="">
|
||||
<ipub-cover>
|
||||
<dialog>
|
||||
<header>
|
||||
<h1>Test comic</h1>
|
||||
<form method="dialog">
|
||||
<p>Click anywhere to
|
||||
<button>start Reading</button></p>
|
||||
</form>
|
||||
</header>
|
||||
</dialog>
|
||||
</ipub-cover>
|
||||
<main id="content">
|
||||
<ipub-background id="background0001">
|
||||
<img src="../images/background0001.jpg" width="100" height="100" />
|
||||
</ipub-background>
|
||||
<ipub-soundtrack style="--ipub-color:cyan">
|
||||
<ipub-trigger height="10" />
|
||||
<!-- TODO: Search on how to make this more accessible, more semantic as using <details> -->
|
||||
<figure>
|
||||
<label>
|
||||
<input type="checkbox" />
|
||||
<figcaption>Soundtrack 1</figcaption>
|
||||
</label>
|
||||
<ipub-audio>
|
||||
<audio controls="true" volume="0" controlslist="nofullscreen"
|
||||
disableremoteplayback="">
|
||||
<source src="../audios/track1.webm" />
|
||||
</audio>
|
||||
</ipub-audio>
|
||||
</figure>
|
||||
</ipub-soundtrack>
|
||||
<ipub-image>
|
||||
<img src="../images/image0001.png" />
|
||||
<ipub-interaction style="--ipub-y:88.5%;--ipub-x:6%" circle="">
|
||||
<a href="https://krita.org" referrerpolicy="same-origin"
|
||||
rel="external nofollow noopener noreferrer" target="_blank" />
|
||||
</ipub-interaction>
|
||||
<ipub-interaction style="--ipub-y:93.5%;--ipub-x:81.5%;--ipub-size:13%;">
|
||||
<a href="https://guz.one" referrerpolicy="same-origin"
|
||||
rel="external nofollow noopener noreferrer" target="_blank" />
|
||||
</ipub-interaction>
|
||||
</ipub-image>
|
||||
<ipub-image>
|
||||
<img src="../images/image0002.png" />
|
||||
</ipub-image>
|
||||
<ipub-soundtrack style="--ipub-color:green;">
|
||||
<ipub-trigger height="10" />
|
||||
<figure>
|
||||
<label>
|
||||
<input type="checkbox" />
|
||||
<figcaption>Soundtrack 2</figcaption>
|
||||
</label>
|
||||
<ipub-audio>
|
||||
<audio controls="true" volume="0" controlslist="nofullscreen"
|
||||
disableremoteplayback="">
|
||||
<source src="../audios/track2.webm" />
|
||||
</audio>
|
||||
</ipub-audio>
|
||||
</figure>
|
||||
</ipub-soundtrack>
|
||||
<ipub-background id="background0002">
|
||||
<picture>
|
||||
<img src="../images/background0002.jpg" />
|
||||
</picture>
|
||||
</ipub-background>
|
||||
<ipub-image>
|
||||
<img src="../images/image0003.png" />
|
||||
</ipub-image>
|
||||
<ipub-image>
|
||||
<img src="../images/image0004.png" />
|
||||
</ipub-image>
|
||||
<ipub-background id="background0003">
|
||||
<picture>
|
||||
<img src="../images/background0003.jpg" />
|
||||
</picture>
|
||||
</ipub-background>
|
||||
<ipub-image>
|
||||
<img src="../images/image0002.png" />
|
||||
</ipub-image>
|
||||
<ipub-soundtrack style="--ipub-color:yellow;">
|
||||
<ipub-trigger height="10" />
|
||||
<figure>
|
||||
<label>
|
||||
<input type="checkbox" />
|
||||
<figcaption>Soundtrack 3</figcaption>
|
||||
</label>
|
||||
<ipub-audio>
|
||||
<audio controls="true" volume="0" controlslist="nofullscreen"
|
||||
disableremoteplayback="">
|
||||
<source src="../audios/track3.webm" />
|
||||
</audio>
|
||||
</ipub-audio>
|
||||
</figure>
|
||||
</ipub-soundtrack>
|
||||
<ipub-image>
|
||||
<img src="../images/image0003.png" />
|
||||
</ipub-image>
|
||||
<ipub-image>
|
||||
<img src="../images/image0004.png" />
|
||||
</ipub-image>
|
||||
</main>
|
||||
</ipub-body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,325 +0,0 @@
|
||||
.body {
|
||||
-epub-writing-mode: horizontal-tb;
|
||||
-webkit-writing-mode: horizontal-tb;
|
||||
direction: ltr;
|
||||
/* direction: rtl; */
|
||||
writing-mode: horizontal-tb;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
overflow: clip;
|
||||
display: flex;
|
||||
|
||||
:root {
|
||||
--z-controls: 10;
|
||||
--z-cover: 9;
|
||||
--z-overlays: 6;
|
||||
}
|
||||
|
||||
[debug] {
|
||||
--ipub-debug-bg-opacity: 30%;
|
||||
background-color: rgba(
|
||||
from var(--ipub-debug-color) r g b / var(--ipub-debug-bg-opacity, 30%)
|
||||
);
|
||||
outline-color: var(--ipub-debug-color);
|
||||
outline-width: 1px;
|
||||
outline-style: solid;
|
||||
}
|
||||
|
||||
ipub-cover > dialog[open] {
|
||||
--ipub-accent-color: #fff;
|
||||
z-index: var(--z-cover);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
backdrop-filter: blur(1rem);
|
||||
background-image: linear-gradient(
|
||||
rgba(from var(--ipub-accent-color) r g b / 0) 0%,
|
||||
rgba(from var(--ipub-accent-color) r g b / 0.5)
|
||||
calc(100% + calc(var(--ipub-fade, 50%) * -1))
|
||||
);
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ipub-body {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
overflow: scroll;
|
||||
&:has(ipub-cover > dialog[open]) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
--ipub-padding: 0%;
|
||||
--ipub-gap: 0%;
|
||||
--ipub-padding-x: var(--ipub-padding, 0%);
|
||||
--ipub-padding-y: var(--ipub-padding, 0%);
|
||||
--ipub-padding-t: var(--ipub-padding-y, 0%);
|
||||
--ipub-padding-r: var(--ipub-padding-x, 0%);
|
||||
--ipub-padding-b: var(--ipub-padding-y, 0%);
|
||||
--ipub-padding-l: var(--ipub-padding-x, 0%);
|
||||
|
||||
& > article,
|
||||
& > main,
|
||||
& > section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
& > *:first-child:not(ipub-background):not(ipub-soundtrack),
|
||||
& > ipub-background:first-of-type + *:first-of-type:not(ipub-soundtrack),
|
||||
& > ipub-soundtrack:first-of-type + *:first-of-type:not(ipub-background) {
|
||||
margin-top: var(--ipub-padding-t);
|
||||
margin-bottom: calc(var(--ipub-gap) / 2);
|
||||
}
|
||||
|
||||
& > *:not(ipub-background):not(ipub-soundtrack) {
|
||||
margin-top: calc(var(--ipub-gap) / 2);
|
||||
margin-right: var(--ipub-padding-r);
|
||||
margin-left: var(--ipub-padding-l);
|
||||
margin-bottom: calc(var(--ipub-gap) / 2);
|
||||
}
|
||||
|
||||
& > *:last-child:not(ipub-background),
|
||||
& > *:last-child:not(ipub-soundtrack),
|
||||
& > ipub-background:last-of-type + *:last-of-type:not(ipub-soundtrack),
|
||||
& > ipub-soundtrack:last-of-type + *:last-of-type:not(ipub-background) {
|
||||
margin-top: calc(var(--ipub-gap) / 2);
|
||||
margin-bottom: var(--ipub-padding-b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ipub-soundtrack {
|
||||
display: inline-block;
|
||||
|
||||
z-index: var(--z-overlays);
|
||||
|
||||
--ipub-color: red;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
position: sticky;
|
||||
align-self: start;
|
||||
|
||||
border-top: 0.1rem dashed var(--ipub-color);
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
height: 1.5rem;
|
||||
font-size: small;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
|
||||
label {
|
||||
background-color: var(--ipub-color);
|
||||
border-end-end-radius: 0.5rem;
|
||||
|
||||
padding: 0.1rem 0.4rem;
|
||||
width: max-content;
|
||||
max-width: 50%;
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:has(input:checked) label {
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
figcaption::before {
|
||||
content: "0 "; /* TODO: change to an icon and better positioning */
|
||||
}
|
||||
|
||||
figcaption::after {
|
||||
content: " >"; /* TODO: change to an icon and better positioning */
|
||||
}
|
||||
&:has(input:checked) figcaption::after {
|
||||
content: " <"; /* TODO: change to an icon and better positioning */
|
||||
}
|
||||
|
||||
input {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:has(input) audio {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&:has(input:checked) audio {
|
||||
width: 100%;
|
||||
@media (width >= 40rem) {
|
||||
width: 70%;
|
||||
}
|
||||
height: 100%;
|
||||
|
||||
background-color: var(--ipub-color);
|
||||
@media (width >= 40rem) {
|
||||
border-end-end-radius: 0.5rem;
|
||||
}
|
||||
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
audio::-webkit-media-controls-enclosure {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0px;
|
||||
}
|
||||
audio::-webkit-media-controls-panel {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[playing] figure figcaption::before {
|
||||
content: "P "; /* TODO: change to an icon and better positioning */
|
||||
}
|
||||
|
||||
& > ipub-trigger {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateY(--ipub-offset, 0%);
|
||||
pointer-events: none;
|
||||
|
||||
width: var(--ipub-width, 100%);
|
||||
height: var(--ipub-height, 0%);
|
||||
}
|
||||
}
|
||||
|
||||
ipub-background {
|
||||
--ipub-mask: linear-gradient(
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 1) calc(100% + calc(var(--ipub-fade, 100%) * -1))
|
||||
);
|
||||
|
||||
--ipub-width: 100vw;
|
||||
--ipub-height: 100vh;
|
||||
|
||||
display: inline-block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: sticky;
|
||||
align-self: start;
|
||||
|
||||
&:first-of-type,
|
||||
&[nofade] {
|
||||
--ipub-mask: unset;
|
||||
}
|
||||
|
||||
img {
|
||||
/* For testing */
|
||||
/* background-image: linear-gradient( */
|
||||
/* rgba(266, 0, 0, 1) 0%, */
|
||||
/* rgba(0, 266, 0, 1) calc(100% + calc(var(--ipub-fade, 100%) * -1)), */
|
||||
/* rgba(266, 0, 266, 1) 100% */
|
||||
/* ) !important; */
|
||||
/* background-image: var(--mask); */
|
||||
mask-image: var(--ipub-mask);
|
||||
-webkit-mask-image: var(--ipub-mask);
|
||||
}
|
||||
|
||||
& > picture {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: var(--ipub-width);
|
||||
height: var(--ipub-height);
|
||||
& > img {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
/* Support standalone img element */
|
||||
& > img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
width: var(--ipub-width);
|
||||
height: var(--ipub-height);
|
||||
}
|
||||
}
|
||||
|
||||
ipub-image {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex-direction: column;
|
||||
|
||||
width: var(--ipub-width, unset);
|
||||
height: var(--ipub-height, unset);
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
ipub-interaction {
|
||||
position: absolute;
|
||||
|
||||
--ipub-x: 0px;
|
||||
--ipub-y: 0px;
|
||||
--ipub-size: 10%;
|
||||
--ipub-width: var(--ipub-size, unset);
|
||||
--ipub-height: unset;
|
||||
--ipub-ratio: 1/1;
|
||||
|
||||
left: var(--ipub-x);
|
||||
top: var(--ipub-y);
|
||||
|
||||
width: var(--ipub-width);
|
||||
height: var(--ipub-height);
|
||||
aspect-ratio: var(--ipub-ratio, unset);
|
||||
transform: translate(var(--ipub-offset-x, -50%), var(--ipub-offset-y, -50%));
|
||||
|
||||
& > * {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: var(--ipub-radius, unset);
|
||||
|
||||
/*
|
||||
* The opacity would be, by default, zero. Here it is 0.3 for easier debugging and
|
||||
* showing of the example ebook
|
||||
*/
|
||||
background-color: red;
|
||||
opacity: 0.3;
|
||||
}
|
||||
&[circle] {
|
||||
--ipub-radius: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head>
|
||||
<meta content="" name="" scheme=""/>
|
||||
</head>
|
||||
<docTitle>
|
||||
<text/>
|
||||
</docTitle>
|
||||
<navMap>
|
||||
<navPoint class="document" id="section1" playOrder="1">
|
||||
<navLabel>
|
||||
<text>Section 1</text>
|
||||
</navLabel>
|
||||
<content src="sections/section0001.xhtml"/>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
</ncx>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head/>
|
||||
<body>
|
||||
<nav epub:type="toc">
|
||||
<ol>
|
||||
<li>
|
||||
<a href="sections/section0001.xhtml">Section 1</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
application/epub+zip
|
||||
5
.example.env
Normal file
5
.example.env
Normal file
@@ -0,0 +1,5 @@
|
||||
AWS_ACCESS_KEY_ID=**************************
|
||||
AWS_SECRET_ACCESS_KEY=****************************************************************
|
||||
AWS_DEFAULT_REGION=******
|
||||
AWS_ENDPOINT_URL=localhost:3000
|
||||
S3_DEFAULT_BUCKET=comicverse-test-bucket
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -1,6 +1,23 @@
|
||||
.dist
|
||||
out.css
|
||||
.tmp
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
*.db
|
||||
tmp
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
data.db
|
||||
|
||||
6
.gitmodules
vendored
6
.gitmodules
vendored
@@ -1,6 +0,0 @@
|
||||
[submodule "x"]
|
||||
path = x
|
||||
url = https://code.capytal.cc:loreddev/x
|
||||
[submodule "smalltrip"]
|
||||
path = smalltrip
|
||||
url = https://code.capytal.cc/loreddev/smalltrip
|
||||
@@ -1,20 +0,0 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- errcheck
|
||||
- goimports
|
||||
- gofumpt
|
||||
- revive # golint
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
20
.vscode/launch.json
vendored
20
.vscode/launch.json
vendored
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch APP",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/cmd/cmd.go"
|
||||
},
|
||||
{
|
||||
"name": "Launch APP (Dev)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/cmd/cmd.go",
|
||||
"args": ["-dev", "-port", "8080", "-hostname", "0.0.0.0"]
|
||||
}
|
||||
]
|
||||
}
|
||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm create svelte@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm create svelte@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||
@@ -1,22 +0,0 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed stylesheets/out.css
|
||||
var files embed.FS
|
||||
|
||||
func Files(local ...bool) fs.FS {
|
||||
var l bool
|
||||
if len(local) > 0 {
|
||||
l = local[0]
|
||||
}
|
||||
|
||||
if !l {
|
||||
return files
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
207
cmd/cmd.go
207
cmd/cmd.go
@@ -1,207 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
comicverse "code.capytal.cc/capytal/comicverse"
|
||||
"code.capytal.cc/capytal/comicverse/templates"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
_ "github.com/tursodatabase/go-libsql"
|
||||
)
|
||||
|
||||
var (
|
||||
hostname = flag.String("hostname", "localhost", "Host to listen to")
|
||||
port = flag.Uint("port", 8080, "Port to be used for the server.")
|
||||
templatesDir = flag.String("templates", "", "Templates directory to be used instead of built-in ones.")
|
||||
verbose = flag.Bool("verbose", false, "Print debug information on logs")
|
||||
dev = flag.Bool("dev", false, "Run the server in debug mode.")
|
||||
)
|
||||
|
||||
var (
|
||||
databaseURL = getEnv("DATABASE_URL", "file:./libsql.db")
|
||||
|
||||
awsAccessKeyID = os.Getenv("AWS_ACCESS_KEY_ID")
|
||||
awsSecretAccessKey = os.Getenv("AWS_SECRET_ACCESS_KEY")
|
||||
awsDefaultRegion = os.Getenv("AWS_DEFAULT_REGION")
|
||||
awsEndpointURL = os.Getenv("AWS_ENDPOINT_URL")
|
||||
s3Bucket = os.Getenv("S3_BUCKET")
|
||||
|
||||
privateKeyEnv = os.Getenv("PRIVATE_KEY")
|
||||
publicKeyEnv = os.Getenv("PUBLIC_KEY")
|
||||
)
|
||||
|
||||
func getEnv(key string, d string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func init() {
|
||||
flag.Parse()
|
||||
|
||||
switch {
|
||||
case databaseURL == "":
|
||||
log.Fatal("DATABASE_URL should not be a empty value")
|
||||
case awsAccessKeyID == "":
|
||||
log.Fatal("AWS_ACCESS_KEY_ID should not be a empty value")
|
||||
case awsDefaultRegion == "":
|
||||
log.Fatal("AWS_DEFAULT_REGION should not be a empty value")
|
||||
case awsEndpointURL == "":
|
||||
log.Fatal("AWS_ENDPOINT_URL should not be a empty value")
|
||||
case s3Bucket == "":
|
||||
log.Fatal("S3_BUCKET should not be a empty value")
|
||||
case privateKeyEnv == "":
|
||||
log.Fatal("PRIVATE_KEY not be a empty value")
|
||||
case publicKeyEnv == "":
|
||||
log.Fatal("PUBLIC_KEY not be a empty value")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
level := slog.LevelError
|
||||
if *dev {
|
||||
level = slog.LevelDebug
|
||||
} else if *verbose {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
assertions := tinyssert.NewDisabled()
|
||||
if *dev {
|
||||
assertions = tinyssert.New(
|
||||
tinyssert.WithPanic(),
|
||||
tinyssert.WithLogger(log),
|
||||
)
|
||||
}
|
||||
|
||||
db, err := sql.Open("libsql", databaseURL)
|
||||
if err != nil {
|
||||
log.Error("Failed open connection to database", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
credentials := aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
|
||||
return aws.Credentials{
|
||||
AccessKeyID: awsAccessKeyID,
|
||||
SecretAccessKey: awsSecretAccessKey,
|
||||
CanExpire: false,
|
||||
}, nil
|
||||
})
|
||||
storage := s3.New(s3.Options{
|
||||
AppID: "comicverse-pre-alpha",
|
||||
BaseEndpoint: &awsEndpointURL,
|
||||
Region: awsDefaultRegion,
|
||||
Credentials: &credentials,
|
||||
})
|
||||
|
||||
opts := []comicverse.Option{
|
||||
comicverse.WithContext(ctx),
|
||||
comicverse.WithAssertions(assertions),
|
||||
comicverse.WithLogger(log),
|
||||
}
|
||||
|
||||
if *dev {
|
||||
d := os.DirFS("./assets")
|
||||
opts = append(opts, comicverse.WithAssets(d))
|
||||
|
||||
t := templates.NewHotTemplates(os.DirFS("./templates"))
|
||||
opts = append(opts, comicverse.WithTemplates(t))
|
||||
|
||||
opts = append(opts, comicverse.WithDevelopmentMode())
|
||||
}
|
||||
|
||||
// TODO: Move this to dedicated function
|
||||
privateKeyStr, err := base64.URLEncoding.DecodeString(privateKeyEnv)
|
||||
if err != nil {
|
||||
log.Error("Failed to decode PRIVATE_KEY from base64", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
publicKeyStr, err := base64.URLEncoding.DecodeString(publicKeyEnv)
|
||||
if err != nil {
|
||||
log.Error("Failed to decode PUBLIC_KEY from base64", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
edPrivKey := ed25519.PrivateKey(privateKeyStr)
|
||||
edPubKey := ed25519.PublicKey(publicKeyStr)
|
||||
|
||||
if len(edPrivKey) != ed25519.PrivateKeySize {
|
||||
log.Error("PRIVATE_KEY is not of valid size", slog.Int("size", len(edPrivKey)))
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(edPubKey) != ed25519.PublicKeySize {
|
||||
log.Error("PUBLIC_KEY is not of valid size", slog.Int("size", len(edPubKey)))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !edPubKey.Equal(edPrivKey.Public()) {
|
||||
log.Error("PUBLIC_KEY is not equal from extracted public key",
|
||||
slog.String("extracted", fmt.Sprintf("%x", edPrivKey.Public())),
|
||||
slog.String("key", fmt.Sprintf("%x", edPubKey)),
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app, err := comicverse.New(comicverse.Config{
|
||||
DB: db,
|
||||
S3: storage,
|
||||
PrivateKey: edPrivKey,
|
||||
PublicKey: edPubKey,
|
||||
Bucket: s3Bucket,
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
log.Error("Failed to initiate comicverse app", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
|
||||
Handler: app,
|
||||
}
|
||||
|
||||
c, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
log.Info("Starting application",
|
||||
slog.String("host", *hostname),
|
||||
slog.Uint64("port", uint64(*port)),
|
||||
slog.Bool("verbose", *verbose),
|
||||
slog.Bool("development", *dev))
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("Failed to start application server", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-c.Done()
|
||||
|
||||
log.Info("Stopping application gracefully")
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log.Info("FINAL")
|
||||
os.Exit(0)
|
||||
}
|
||||
196
comicverse.go
196
comicverse.go
@@ -1,196 +0,0 @@
|
||||
package comicverse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/assets"
|
||||
"code.capytal.cc/capytal/comicverse/internals/joinedfs"
|
||||
"code.capytal.cc/capytal/comicverse/repository"
|
||||
"code.capytal.cc/capytal/comicverse/router"
|
||||
"code.capytal.cc/capytal/comicverse/service"
|
||||
"code.capytal.cc/capytal/comicverse/templates"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
func New(cfg Config, opts ...Option) (http.Handler, error) {
|
||||
app := &app{
|
||||
db: cfg.DB,
|
||||
s3: cfg.S3,
|
||||
bucket: cfg.Bucket,
|
||||
privateKey: cfg.PrivateKey,
|
||||
publicKey: cfg.PublicKey,
|
||||
|
||||
assets: assets.Files(),
|
||||
templates: templates.Templates(),
|
||||
developmentMode: false,
|
||||
ctx: context.Background(),
|
||||
|
||||
assert: tinyssert.New(),
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(app)
|
||||
}
|
||||
|
||||
if app.db == nil {
|
||||
return nil, errors.New("database interface must not be nil")
|
||||
}
|
||||
if app.s3 == nil {
|
||||
return nil, errors.New("s3 client must not be nil")
|
||||
}
|
||||
if app.privateKey == nil || len(app.privateKey) == 0 {
|
||||
return nil, errors.New("private key client must not be nil")
|
||||
}
|
||||
if app.publicKey == nil || len(app.publicKey) == 0 {
|
||||
return nil, errors.New("public key client must not be nil")
|
||||
}
|
||||
if app.bucket == "" {
|
||||
return nil, errors.New("bucket must not be a empty string")
|
||||
}
|
||||
|
||||
if app.assets == nil {
|
||||
return nil, errors.New("static files must not be a nil interface")
|
||||
}
|
||||
if app.templates == nil {
|
||||
return nil, errors.New("templates must not be a nil interface")
|
||||
}
|
||||
|
||||
if app.ctx == nil {
|
||||
return nil, errors.New("context must not be a nil interface")
|
||||
}
|
||||
|
||||
if app.logger == nil {
|
||||
return nil, errors.New("logger must not be a nil interface")
|
||||
}
|
||||
|
||||
if app.assert == nil {
|
||||
return nil, errors.New("assertions must not be a nil interface")
|
||||
}
|
||||
|
||||
return app, app.setup()
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DB *sql.DB
|
||||
S3 *s3.Client
|
||||
Bucket string
|
||||
PrivateKey ed25519.PrivateKey // TODO: Put this inside a service so we can easily rotate keys
|
||||
PublicKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
type Option func(*app)
|
||||
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return func(app *app) { app.ctx = ctx }
|
||||
}
|
||||
|
||||
func WithAssets(f fs.FS) Option {
|
||||
return func(app *app) { app.assets = joinedfs.Join(f, app.assets) }
|
||||
}
|
||||
|
||||
func WithTemplates(t templates.ITemplate) Option {
|
||||
return func(app *app) { app.templates = t }
|
||||
}
|
||||
|
||||
func WithAssertions(a tinyssert.Assertions) Option {
|
||||
return func(app *app) { app.assert = a }
|
||||
}
|
||||
|
||||
func WithLogger(l *slog.Logger) Option {
|
||||
return func(app *app) { app.logger = l }
|
||||
}
|
||||
|
||||
func WithDevelopmentMode() Option {
|
||||
return func(app *app) { app.developmentMode = true }
|
||||
}
|
||||
|
||||
type app struct {
|
||||
db *sql.DB
|
||||
s3 *s3.Client
|
||||
bucket string
|
||||
privateKey ed25519.PrivateKey
|
||||
publicKey ed25519.PublicKey
|
||||
|
||||
ctx context.Context
|
||||
|
||||
assets fs.FS
|
||||
templates templates.ITemplate
|
||||
developmentMode bool
|
||||
|
||||
handler http.Handler
|
||||
|
||||
assert tinyssert.Assertions
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (app *app) setup() error {
|
||||
app.assert.NotNil(app.db)
|
||||
app.assert.NotNil(app.s3)
|
||||
app.assert.NotZero(app.bucket)
|
||||
app.assert.NotNil(app.ctx)
|
||||
app.assert.NotNil(app.assets)
|
||||
app.assert.NotNil(app.logger)
|
||||
|
||||
userRepository, err := repository.NewUser(app.ctx, app.db, app.logger.WithGroup("repository.user"), app.assert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: failed to start user repository: %w", err)
|
||||
}
|
||||
|
||||
tokenRepository, err := repository.NewToken(app.ctx, app.db, app.logger.WithGroup("repository.token"), app.assert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: failed to start token repository: %w", err)
|
||||
}
|
||||
|
||||
publicationRepository, err := repository.NewPublication(app.ctx, app.db, app.logger.WithGroup("repository.publication"), app.assert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: failed to start publication repository: %w", err)
|
||||
}
|
||||
|
||||
permissionRepository, err := repository.NewPermissions(app.ctx, app.db, app.logger.WithGroup("repository.permission"), app.assert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: failed to start permission repository: %w", err)
|
||||
}
|
||||
|
||||
userService := service.NewUser(userRepository, app.logger.WithGroup("service.user"), app.assert)
|
||||
tokenService := service.NewToken(service.TokenConfig{
|
||||
PrivateKey: app.privateKey,
|
||||
PublicKey: app.publicKey,
|
||||
Repository: tokenRepository,
|
||||
Logger: app.logger.WithGroup("service.token"),
|
||||
Assertions: app.assert,
|
||||
})
|
||||
publicationService := service.NewPublication(publicationRepository, permissionRepository, app.logger.WithGroup("service.publication"), app.assert)
|
||||
|
||||
app.handler, err = router.New(router.Config{
|
||||
UserService: userService,
|
||||
TokenService: tokenService,
|
||||
PublicationService: publicationService,
|
||||
|
||||
Templates: app.templates,
|
||||
DisableCache: app.developmentMode,
|
||||
Assets: app.assets,
|
||||
|
||||
Assertions: app.assert,
|
||||
Logger: app.logger.WithGroup("router"),
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to initiate router"), err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (app *app) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
app.assert.NotNil(app.handler)
|
||||
app.handler.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed css/style.css js/*.js ipub/*.js ipub/*.css
|
||||
var files embed.FS
|
||||
|
||||
func New() fs.FS {
|
||||
return files
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--color-gray-50: oklch(98.5% 0.002 247.839);
|
||||
--color-gray-600: oklch(44.6% 0.03 256.802);
|
||||
--color-gray-700: oklch(37.3% 0.034 259.733);
|
||||
--color-gray-900: oklch(21% 0.034 264.665);
|
||||
--spacing: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid;
|
||||
}
|
||||
html, :host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b, strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol, ul, menu {
|
||||
list-style: none;
|
||||
}
|
||||
img, svg, video, canvas, audio, iframe, embed, object {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentcolor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-calendar-picker-indicator {
|
||||
line-height: 1;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden='until-found'])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.bg-gray-900 {
|
||||
background-color: var(--color-gray-900);
|
||||
}
|
||||
.text-gray-50 {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
.has-\[\#first-publication\]\:h-full {
|
||||
&:has(*:is(#first-publication)) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.has-\[\#first-publication\]\:h-svw {
|
||||
&:has(*:is(#first-publication)) {
|
||||
height: 100svw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
form {
|
||||
input {
|
||||
background-color: var(--color-gray-700);
|
||||
border-radius: var(--radius-md);
|
||||
padding-inline-start: calc(var(--spacing) * 2);
|
||||
&:has(+ button) {
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
}
|
||||
}
|
||||
button {
|
||||
background-color: var(--color-gray-600);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 calc(var(--spacing) * 2);
|
||||
input + & {
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
form {
|
||||
input {
|
||||
background-color: var(--color-gray-700);
|
||||
border-radius: var(--radius-md);
|
||||
padding-inline-start: --spacing(2);
|
||||
&:has(+ button) {
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
}
|
||||
}
|
||||
button {
|
||||
background-color: var(--color-gray-600);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 --spacing(2);
|
||||
input + & {
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor"
|
||||
"code.capytal.cc/capytal/comicverse/editor/assets"
|
||||
"code.capytal.cc/capytal/comicverse/editor/router"
|
||||
"code.capytal.cc/capytal/comicverse/editor/storage"
|
||||
"code.capytal.cc/capytal/comicverse/editor/template"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
)
|
||||
|
||||
var (
|
||||
hostname = flag.String("hostname", "localhost", "Host to listen to")
|
||||
port = flag.Uint("port", 8080, "Port to be used for the server.")
|
||||
verbose = flag.Bool("verbose", false, "Print debug information on logs")
|
||||
dev = flag.Bool("dev", false, "Run the server in debug mode.")
|
||||
)
|
||||
|
||||
var (
|
||||
storageDir = getEnv("EDITOR_PUBLICATIONS_DIR", ".publications") // TODO: Use XDG_STATE_HOME as default
|
||||
assetsDir = getEnv("EDITOR_ASSETS_DIR", "assets") // TODO: Use XDG_CONFIG_HOME as default
|
||||
templatesDir = getEnv("EDITOR_TEMPLATES_DIR", "template") // TODO: Use XDG_CONFIG_HOME as default
|
||||
)
|
||||
|
||||
func getEnv(key string, d string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func init() {
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
|
||||
assert := tinyssert.New(tinyssert.WithLogger(log))
|
||||
|
||||
assets := assets.New()
|
||||
templater, err := template.New()
|
||||
if err != nil {
|
||||
log.Error("Unable to initiate templater due to error", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
if *dev {
|
||||
assets = os.DirFS(assetsDir)
|
||||
log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
assert = tinyssert.New(tinyssert.WithPanic(), tinyssert.WithLogger(log.WithGroup("assertions")))
|
||||
templater, err = template.Dev(os.DirFS(templatesDir))
|
||||
if err != nil {
|
||||
log.Error("Unable to initiate dev templater due to error", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = os.MkdirAll(storageDir, os.ModePerm)
|
||||
if err != nil {
|
||||
log.Error("Unable to create storage directory due to error", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(storageDir)
|
||||
if err != nil {
|
||||
log.Error("Unable to open storage directory due to error", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
storage := storage.Newlocal(root, log)
|
||||
|
||||
editor := editor.New(storage, log.WithGroup("editor"), assert)
|
||||
|
||||
router := router.New(router.Config{
|
||||
Assets: assets,
|
||||
Editor: editor,
|
||||
Templater: templater,
|
||||
Logger: log.WithGroup("router"),
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
c, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
log.Info("Starting application",
|
||||
slog.String("host", *hostname),
|
||||
slog.Uint64("port", uint64(*port)),
|
||||
slog.Bool("verbose", *verbose),
|
||||
slog.Bool("development", *dev))
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("Failed to start application server", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-c.Done()
|
||||
|
||||
log.Info("Stopping application gracefully")
|
||||
if err := srv.Shutdown(c); err != nil {
|
||||
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log.Info("FINAL")
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package editor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor/epub"
|
||||
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
|
||||
"code.capytal.cc/capytal/comicverse/editor/storage"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
id uuid.UUID
|
||||
|
||||
pkg epub.Package
|
||||
storage storage.Storage
|
||||
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
|
||||
flushed bool
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func (p *Container) Flush() error {
|
||||
p.assert.NotZero(p.pkg, "invalid ePUB: package must be set")
|
||||
p.assert.NotZero(p.pkg.Metadata, "invalid ePUB: package must have metadata")
|
||||
p.assert.NotZero(p.pkg.Metadata.ID, "invalid ePUB: ID must always be specified")
|
||||
p.assert.NotZero(p.pkg.Metadata.Language, "invalid ePUB: Language must always be specified")
|
||||
p.assert.NotZero(p.pkg.Metadata.Title, "invalid ePUB: Title must always be specified")
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.log.Debug("Flushing state of publication")
|
||||
|
||||
if p.flushed {
|
||||
p.log.Debug("Publication doesn't have unsaved changes, skipping flush")
|
||||
return nil
|
||||
}
|
||||
|
||||
defer p.log.Debug("Publication's state flushed")
|
||||
|
||||
b, err := xml.MarshalIndent(p.pkg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("editor.Publication: failed to marshal package: %w", err)
|
||||
}
|
||||
|
||||
if _, err = p.storage.Write("content.opf", b); err != nil {
|
||||
return fmt.Errorf("editor.Publication: failed to write content.opf: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
114
editor/editor.go
114
editor/editor.go
@@ -1,114 +0,0 @@
|
||||
package editor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor/epub"
|
||||
"code.capytal.cc/capytal/comicverse/editor/storage"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func New(
|
||||
storage storage.Storage,
|
||||
logger *slog.Logger,
|
||||
assert tinyssert.Assertions,
|
||||
) *Editor {
|
||||
assert.NotZero(storage)
|
||||
assert.NotZero(logger)
|
||||
|
||||
return &Editor{
|
||||
storage: storage,
|
||||
|
||||
log: logger,
|
||||
assert: assert,
|
||||
}
|
||||
}
|
||||
|
||||
type Editor struct {
|
||||
storage storage.Storage
|
||||
|
||||
ctx context.Context
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func (e *Editor) New(id uuid.UUID, title string, lang language.Tag) (*Container, error) {
|
||||
f := fmt.Sprintf("%s/content.opf", id)
|
||||
if e.storage.Exists(f) {
|
||||
return nil, ErrAlreadyExists
|
||||
}
|
||||
|
||||
pub := &Container{
|
||||
id: id,
|
||||
|
||||
pkg: epub.Package{
|
||||
Metadata: epub.Metadata{
|
||||
ID: fmt.Sprintf("comicverse:%s", id),
|
||||
Title: title,
|
||||
Language: lang,
|
||||
Date: time.Now(),
|
||||
Modified: time.Now(),
|
||||
},
|
||||
},
|
||||
|
||||
log: e.log.WithGroup(fmt.Sprintf("publication:%s", id)),
|
||||
assert: e.assert,
|
||||
|
||||
storage: storage.WithRoot(id.String(), e.storage),
|
||||
}
|
||||
|
||||
err := pub.Flush()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("editor: unable to flush changes of publication: %w", err)
|
||||
}
|
||||
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
func (e *Editor) Open(id uuid.UUID) (*Container, error) {
|
||||
content, err := e.storage.Open(fmt.Sprintf("%s/content.opf", id))
|
||||
if errors.Is(err, storage.ErrNotExists) {
|
||||
return nil, ErrNotExists
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("editor: unable to open package: %w", err)
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("editor: unable to read contents of package: %w", err)
|
||||
}
|
||||
|
||||
var pkg epub.Package
|
||||
|
||||
err = xml.Unmarshal(b, &pkg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("editor: unable to decode xml of package: %w", err)
|
||||
}
|
||||
|
||||
c := &Container{
|
||||
id: id,
|
||||
|
||||
pkg: pkg,
|
||||
|
||||
log: e.log.WithGroup(fmt.Sprintf("publication:%s", id)),
|
||||
assert: e.assert,
|
||||
|
||||
storage: storage.WithRoot(id.String(), e.storage),
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAlreadyExists = errors.New("editor: file already exists")
|
||||
ErrNotExists = errors.New("editor: file doesn't exist")
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
package epub
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Meta struct {
|
||||
Attributes map[string]string `xml:"-"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ xml.Marshaler = Meta{}
|
||||
_ xml.Unmarshaler = (*Meta)(nil)
|
||||
)
|
||||
|
||||
func (m Meta) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
for n, v := range m.Attributes {
|
||||
start.Attr = append(start.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: n},
|
||||
Value: v,
|
||||
})
|
||||
}
|
||||
return e.EncodeElement(m.Value, start)
|
||||
}
|
||||
|
||||
func (m *Meta) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if m == nil {
|
||||
m = &Meta{}
|
||||
}
|
||||
if m.Attributes == nil {
|
||||
m.Attributes = map[string]string{}
|
||||
}
|
||||
|
||||
for _, attr := range start.Attr {
|
||||
m.Attributes[attr.Name.Local] = attr.Value
|
||||
}
|
||||
|
||||
if err := d.DecodeElement(&m.Value, &start); err != nil {
|
||||
return fmt.Errorf("epub.Meta: failed to decode chardata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
package epub
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `xml:"metadata"`
|
||||
Manifest Manisfest `xml:"manifest"`
|
||||
Spine Spine `xml:"spine"`
|
||||
|
||||
// TODO: Collections https://www.w3.org/TR/epub-33/#sec-pkg-collections
|
||||
}
|
||||
|
||||
var _ xml.Marshaler = Package{}
|
||||
|
||||
func (p Package) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
start.Name = xml.Name{
|
||||
Local: "package",
|
||||
Space: "http://www.idpf.org/2007/opf",
|
||||
}
|
||||
|
||||
start.Attr = append(start.Attr, []xml.Attr{
|
||||
{Name: xml.Name{Local: "xmlns:dc"}, Value: "http://purl.org/dc/elements/1.1/"},
|
||||
{Name: xml.Name{Local: "xmlns:dcterms"}, Value: "http://purl.org/dc/terms/"},
|
||||
{Name: xml.Name{Local: "xmlns:opf"}, Value: "http://www.idpf.org/2007/opf"},
|
||||
{Name: xml.Name{Local: "unique-identifier"}, Value: uniqueIdentifierID},
|
||||
{Name: xml.Name{Local: "version"}, Value: "3.0"},
|
||||
}...)
|
||||
|
||||
if err := e.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := e.EncodeElement(p.Metadata, xml.StartElement{Name: xml.Name{Local: "metadata"}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = e.EncodeElement(p.Manifest, xml.StartElement{Name: xml.Name{Local: "manifest"}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = e.EncodeElement(p.Spine, xml.StartElement{Name: xml.Name{Local: "spine"}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.EncodeToken(xml.EndElement{Name: start.Name})
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
ID string `xml:"dc:identifier"`
|
||||
Title string `xml:"dc:title"`
|
||||
Language language.Tag `xml:"dc:language"`
|
||||
Creators []Person `xml:"dc:creator"`
|
||||
Contributors []Person `xml:"dc:contributor"`
|
||||
Date time.Time `xml:"dc:date"`
|
||||
Modified time.Time `xml:"-"`
|
||||
|
||||
// TODO: Support for dc:subject, dc:type and meta elements
|
||||
// https://www.w3.org/TR/epub-33/#sec-opf-dcsubject
|
||||
}
|
||||
|
||||
var (
|
||||
_ xml.Marshaler = Metadata{}
|
||||
_ xml.Unmarshaler = (*Metadata)(nil)
|
||||
)
|
||||
|
||||
func (m Metadata) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if err := e.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
helper := encoderHelper(e, "epub.Metadata")
|
||||
|
||||
err := helper("dc:identifier", m.ID, xml.Attr{
|
||||
Name: xml.Name{Local: "id"}, Value: uniqueIdentifierID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = helper("dc:title", m.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = helper("dc:language", m.Language.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, creator := range m.Creators {
|
||||
err := creator.marshalIntoRootXML(xml.Name{Local: "dc:creator"}, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, contributor := range m.Contributors {
|
||||
err := contributor.marshalIntoRootXML(xml.Name{Local: "dc:contributor"}, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !m.Date.IsZero() {
|
||||
if err = helper("dc:date", m.Date.Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !m.Modified.IsZero() {
|
||||
if err = helper("meta", m.Modified.Format(time.RFC3339), xml.Attr{
|
||||
Name: xml.Name{Local: "property"}, Value: "dcterms:modified",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.EncodeToken(start.End())
|
||||
}
|
||||
|
||||
func (m *Metadata) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if m == nil {
|
||||
m = &Metadata{}
|
||||
}
|
||||
|
||||
var v struct {
|
||||
ID string `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
||||
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
Language language.Tag `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||
Creators []Person `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
Contributors []Person `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
||||
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
|
||||
Meta []Meta `xml:"meta"`
|
||||
}
|
||||
|
||||
if err := d.DecodeElement(&v, &start); err != nil {
|
||||
return fmt.Errorf("epub.Metadata: unable to unmarshal: %w", err)
|
||||
}
|
||||
|
||||
m.ID = v.ID
|
||||
m.Title = v.Title
|
||||
m.Language = v.Language
|
||||
|
||||
if v.Date != "" {
|
||||
t, err := time.Parse(time.RFC3339, v.Date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epub.Metadata: date is not valid: %w", err)
|
||||
}
|
||||
m.Date = t
|
||||
}
|
||||
|
||||
m.Creators = v.Creators
|
||||
|
||||
for i, c := range m.Creators {
|
||||
c, err := c.unmarshalFromMetas(v.Meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
|
||||
}
|
||||
m.Creators[i] = c
|
||||
}
|
||||
|
||||
m.Contributors = v.Contributors
|
||||
|
||||
for i, c := range m.Contributors {
|
||||
c, err := c.unmarshalFromMetas(v.Meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
|
||||
}
|
||||
m.Contributors[i] = c
|
||||
}
|
||||
|
||||
for _, meta := range v.Meta {
|
||||
if property, ok := meta.Attributes["property"]; ok {
|
||||
switch property {
|
||||
case "dcterms:modified":
|
||||
t, err := time.Parse(time.RFC3339, meta.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epub.Metadata: modified date is not valid: %w", err)
|
||||
}
|
||||
m.Modified = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var uniqueIdentifierID = "pub-id"
|
||||
|
||||
type Person struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:",chardata"`
|
||||
Role string `xml:"-"`
|
||||
FileAs string `xml:"-"`
|
||||
|
||||
AlternateScripts map[language.Tag]string `xml:"-"`
|
||||
}
|
||||
|
||||
func (p Person) marshalIntoRootXML(name xml.Name, e *xml.Encoder) error {
|
||||
if p.ID == "" {
|
||||
p.ID = shortid.New().String()
|
||||
}
|
||||
|
||||
err := e.EncodeElement(p.Name, xml.StartElement{
|
||||
Name: name,
|
||||
Attr: []xml.Attr{{Name: xml.Name{Local: "id"}, Value: p.ID}},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for lang, name := range p.AlternateScripts {
|
||||
err = e.EncodeElement(name, xml.StartElement{
|
||||
Name: xml.Name{Local: "meta"},
|
||||
Attr: []xml.Attr{
|
||||
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
||||
{Name: xml.Name{Local: "property"}, Value: "alternate-script"},
|
||||
{Name: xml.Name{Local: "xml:lang"}, Value: lang.String()},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if p.FileAs != "" {
|
||||
err = e.EncodeElement(p.FileAs, xml.StartElement{
|
||||
Name: xml.Name{Local: "meta"},
|
||||
Attr: []xml.Attr{
|
||||
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
||||
{Name: xml.Name{Local: "property"}, Value: "file-as"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if p.Role != "" {
|
||||
err = e.EncodeElement(p.Role, xml.StartElement{
|
||||
Name: xml.Name{Local: "meta"},
|
||||
Attr: []xml.Attr{
|
||||
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
||||
{Name: xml.Name{Local: "property"}, Value: "role"},
|
||||
{Name: xml.Name{Local: "scheme"}, Value: "marc:relators"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Person) unmarshalFromMetas(metaList []Meta) (Person, error) {
|
||||
if p.ID == "" {
|
||||
return p, nil
|
||||
}
|
||||
if p.AlternateScripts == nil {
|
||||
p.AlternateScripts = map[language.Tag]string{}
|
||||
}
|
||||
|
||||
for _, meta := range metaList {
|
||||
refines, ok := meta.Attributes["refines"]
|
||||
if !ok || refines != fmt.Sprintf("#%s", p.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
property, ok := meta.Attributes["property"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch property {
|
||||
case "alternate-script":
|
||||
l, ok := meta.Attributes["lang"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lang, err := language.Parse(l)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("epub.Person: language %q is not valid: %w", l, err)
|
||||
}
|
||||
p.AlternateScripts[lang] = meta.Value
|
||||
case "file-as":
|
||||
p.FileAs = meta.Value
|
||||
case "role":
|
||||
p.Role = meta.Value
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
type Manisfest struct {
|
||||
Items []Item `xml:"item"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID string `xml:"id,attr"`
|
||||
HRef string `xml:"href,attr"`
|
||||
MediaType string `xml:"media-type,attr"`
|
||||
MediaOverlay string `xml:"media-overlay,attr,omitempty"`
|
||||
Properties ItemProperties `xml:"properties,attr,omitempty"`
|
||||
}
|
||||
|
||||
type Spine struct {
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
Toc string `xml:"toc,attr,omitempty"`
|
||||
|
||||
PageProgressionDir PageProgressionDir `xml:"page-progression-direction,attr,omitempty"`
|
||||
|
||||
ItemRefs []ItemRef `xml:"itemref"`
|
||||
}
|
||||
|
||||
type PageProgressionDir string
|
||||
|
||||
const (
|
||||
PageProgressionDirDefault PageProgressionDir = "default"
|
||||
PageProgressionDirLTR PageProgressionDir = "ltr"
|
||||
PageProgressionDirRTL PageProgressionDir = "rtl"
|
||||
)
|
||||
|
||||
type ItemRef struct {
|
||||
IDRef string `xml:"idref,attr"`
|
||||
ID string `xml:"id,attr"`
|
||||
NotLinear bool `xml:"linear,attr"`
|
||||
Properties ItemProperties `xml:"properties,attr"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ xml.Marshaler = ItemRef{}
|
||||
_ xml.Unmarshaler = (*ItemRef)(nil)
|
||||
)
|
||||
|
||||
func (ref ItemRef) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
linear := xml.Attr{Name: xml.Name{Local: "linear"}}
|
||||
if !ref.NotLinear {
|
||||
linear.Value = "no"
|
||||
} else {
|
||||
linear.Value = "yes"
|
||||
}
|
||||
|
||||
props, _ := ref.Properties.MarshalXMLAttr(xml.Name{Local: "properties"})
|
||||
|
||||
start.Attr = append(start.Attr, []xml.Attr{
|
||||
{Name: xml.Name{Local: "idref"}, Value: ref.IDRef},
|
||||
{Name: xml.Name{Local: "id"}, Value: ref.ID},
|
||||
linear,
|
||||
props,
|
||||
}...)
|
||||
|
||||
if err := e.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.EncodeToken(start.End())
|
||||
}
|
||||
|
||||
func (ref *ItemRef) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if ref == nil {
|
||||
ref = &ItemRef{}
|
||||
}
|
||||
for _, attr := range start.Attr {
|
||||
switch attr.Name.Local {
|
||||
case "idref":
|
||||
ref.IDRef = attr.Value
|
||||
case "id":
|
||||
ref.ID = attr.Value
|
||||
case "linear":
|
||||
if attr.Value == "no" {
|
||||
ref.NotLinear = true
|
||||
} else {
|
||||
ref.NotLinear = false
|
||||
}
|
||||
case "properties":
|
||||
ref.Properties.UnmarshalXMLAttr(attr)
|
||||
}
|
||||
}
|
||||
var t string
|
||||
return d.DecodeElement(&t, &start)
|
||||
}
|
||||
|
||||
type (
|
||||
ItemProperty string
|
||||
ItemProperties []ItemProperty
|
||||
)
|
||||
|
||||
const (
|
||||
ItemPropertyCoverImage ItemProperty = "cover-image"
|
||||
ItemPropertyNav ItemProperty = "nav"
|
||||
ItemPropertyMathML ItemProperty = "mathml"
|
||||
ItemPropertyRemoteResources ItemProperty = "remote-resources"
|
||||
ItemPropertyScripted ItemProperty = "scripted"
|
||||
ItemPropertySVG ItemProperty = "svg"
|
||||
ItemPropertySwitch ItemProperty = "switch"
|
||||
)
|
||||
|
||||
var (
|
||||
_ xml.MarshalerAttr = (ItemProperties)(nil)
|
||||
_ xml.UnmarshalerAttr = (*ItemProperties)(nil)
|
||||
)
|
||||
|
||||
func (is ItemProperties) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
|
||||
strs := make([]string, len(is))
|
||||
for i := range is {
|
||||
strs[i] = string(is[i])
|
||||
}
|
||||
return xml.Attr{Name: name, Value: strings.Join(strs, " ")}, nil
|
||||
}
|
||||
|
||||
func (is *ItemProperties) UnmarshalXMLAttr(attr xml.Attr) error {
|
||||
if is == nil {
|
||||
is = &ItemProperties{}
|
||||
}
|
||||
for s := range strings.SplitSeq(attr.Value, " ") {
|
||||
*is = append(*is, ItemProperty(s))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encoderHelper(e *xml.Encoder, errPrefix ...string) func(
|
||||
key string, value string, attrs ...xml.Attr,
|
||||
) error {
|
||||
if len(errPrefix) == 0 {
|
||||
errPrefix[0] = ""
|
||||
} else {
|
||||
errPrefix[0] = fmt.Sprintf("%s: ", errPrefix[0])
|
||||
}
|
||||
|
||||
return func(key string, value string, attrs ...xml.Attr) error {
|
||||
err := e.EncodeElement(value, xml.StartElement{
|
||||
Name: xml.Name{Local: key},
|
||||
Attr: attrs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%sfailed to encode %q: %w", errPrefix[0], key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
module code.capytal.cc/capytal/comicverse/editor
|
||||
|
||||
go 1.25.2
|
||||
|
||||
require (
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/text v0.31.0
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e h1:LdkirHDzhkcnhOBnDN0po84DjHAAkGztjHu/4mfWpSI=
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e/go.mod h1:jMvSPUj295pTk/ixyxZfwZJE/RQ7DZzvQ3cVoAklkPA=
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1 h1:BE0QdvwVVTG/t7nwNO5rrLf1vdAc5axv/1mWd/oAWhw=
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1/go.mod h1:p5ZPHzutdbUDfpvNBCjv5ls6rM4YNl2k4ipD5b0aRho=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
@@ -1,8 +0,0 @@
|
||||
Adjectives and names list files were copied from Dustin Kirkland's <dustin.kirkland@gmail.com>
|
||||
petname project at Github, specifically from these files:
|
||||
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/adjectives.txt
|
||||
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/names.txt
|
||||
|
||||
The original files are provided and released under the Apache License version 2,
|
||||
which a copy is available at
|
||||
https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/LICENSE
|
||||
@@ -1,449 +0,0 @@
|
||||
able
|
||||
above
|
||||
absolute
|
||||
accepted
|
||||
accurate
|
||||
ace
|
||||
active
|
||||
actual
|
||||
adapted
|
||||
adapting
|
||||
adequate
|
||||
adjusted
|
||||
advanced
|
||||
alert
|
||||
alive
|
||||
allowed
|
||||
allowing
|
||||
amazed
|
||||
amazing
|
||||
ample
|
||||
amused
|
||||
amusing
|
||||
apparent
|
||||
apt
|
||||
arriving
|
||||
artistic
|
||||
assured
|
||||
assuring
|
||||
awaited
|
||||
awake
|
||||
aware
|
||||
balanced
|
||||
becoming
|
||||
beloved
|
||||
better
|
||||
big
|
||||
blessed
|
||||
bold
|
||||
boss
|
||||
brave
|
||||
brief
|
||||
bright
|
||||
bursting
|
||||
busy
|
||||
calm
|
||||
capable
|
||||
capital
|
||||
careful
|
||||
caring
|
||||
casual
|
||||
causal
|
||||
central
|
||||
certain
|
||||
champion
|
||||
charmed
|
||||
charming
|
||||
cheerful
|
||||
chief
|
||||
choice
|
||||
civil
|
||||
classic
|
||||
clean
|
||||
clear
|
||||
clever
|
||||
climbing
|
||||
close
|
||||
closing
|
||||
coherent
|
||||
comic
|
||||
communal
|
||||
complete
|
||||
composed
|
||||
concise
|
||||
concrete
|
||||
content
|
||||
cool
|
||||
correct
|
||||
cosmic
|
||||
crack
|
||||
creative
|
||||
credible
|
||||
crisp
|
||||
crucial
|
||||
cuddly
|
||||
cunning
|
||||
curious
|
||||
current
|
||||
cute
|
||||
daring
|
||||
darling
|
||||
dashing
|
||||
dear
|
||||
decent
|
||||
deciding
|
||||
deep
|
||||
definite
|
||||
delicate
|
||||
desired
|
||||
destined
|
||||
devoted
|
||||
direct
|
||||
discrete
|
||||
distinct
|
||||
diverse
|
||||
divine
|
||||
dominant
|
||||
driven
|
||||
driving
|
||||
dynamic
|
||||
eager
|
||||
easy
|
||||
electric
|
||||
elegant
|
||||
emerging
|
||||
eminent
|
||||
enabled
|
||||
enabling
|
||||
endless
|
||||
engaged
|
||||
engaging
|
||||
enhanced
|
||||
enjoyed
|
||||
enormous
|
||||
enough
|
||||
epic
|
||||
equal
|
||||
equipped
|
||||
eternal
|
||||
ethical
|
||||
evident
|
||||
evolved
|
||||
evolving
|
||||
exact
|
||||
excited
|
||||
exciting
|
||||
exotic
|
||||
expert
|
||||
factual
|
||||
fair
|
||||
faithful
|
||||
famous
|
||||
fancy
|
||||
fast
|
||||
feasible
|
||||
fine
|
||||
finer
|
||||
firm
|
||||
first
|
||||
fit
|
||||
fitting
|
||||
fleet
|
||||
flexible
|
||||
flowing
|
||||
fluent
|
||||
flying
|
||||
fond
|
||||
frank
|
||||
free
|
||||
fresh
|
||||
full
|
||||
fun
|
||||
funky
|
||||
funny
|
||||
game
|
||||
generous
|
||||
gentle
|
||||
genuine
|
||||
giving
|
||||
glad
|
||||
glorious
|
||||
glowing
|
||||
golden
|
||||
good
|
||||
gorgeous
|
||||
grand
|
||||
grateful
|
||||
great
|
||||
growing
|
||||
grown
|
||||
guided
|
||||
guiding
|
||||
handy
|
||||
happy
|
||||
hardy
|
||||
harmless
|
||||
healthy
|
||||
helped
|
||||
helpful
|
||||
helping
|
||||
heroic
|
||||
hip
|
||||
holy
|
||||
honest
|
||||
hopeful
|
||||
hot
|
||||
huge
|
||||
humane
|
||||
humble
|
||||
humorous
|
||||
ideal
|
||||
immense
|
||||
immortal
|
||||
immune
|
||||
improved
|
||||
in
|
||||
included
|
||||
infinite
|
||||
informed
|
||||
innocent
|
||||
inspired
|
||||
integral
|
||||
intense
|
||||
intent
|
||||
internal
|
||||
intimate
|
||||
inviting
|
||||
joint
|
||||
just
|
||||
keen
|
||||
key
|
||||
kind
|
||||
knowing
|
||||
known
|
||||
large
|
||||
lasting
|
||||
leading
|
||||
learning
|
||||
legal
|
||||
legible
|
||||
lenient
|
||||
liberal
|
||||
light
|
||||
liked
|
||||
literate
|
||||
live
|
||||
living
|
||||
logical
|
||||
loved
|
||||
loving
|
||||
loyal
|
||||
lucky
|
||||
magical
|
||||
magnetic
|
||||
main
|
||||
major
|
||||
many
|
||||
massive
|
||||
master
|
||||
mature
|
||||
maximum
|
||||
measured
|
||||
meet
|
||||
merry
|
||||
mighty
|
||||
mint
|
||||
model
|
||||
modern
|
||||
modest
|
||||
moral
|
||||
more
|
||||
moved
|
||||
moving
|
||||
musical
|
||||
mutual
|
||||
national
|
||||
native
|
||||
natural
|
||||
nearby
|
||||
neat
|
||||
needed
|
||||
neutral
|
||||
new
|
||||
next
|
||||
nice
|
||||
noble
|
||||
normal
|
||||
notable
|
||||
noted
|
||||
novel
|
||||
obliging
|
||||
on
|
||||
one
|
||||
open
|
||||
optimal
|
||||
optimum
|
||||
organic
|
||||
oriented
|
||||
outgoing
|
||||
patient
|
||||
peaceful
|
||||
perfect
|
||||
pet
|
||||
picked
|
||||
pleasant
|
||||
pleased
|
||||
pleasing
|
||||
poetic
|
||||
polished
|
||||
polite
|
||||
popular
|
||||
positive
|
||||
possible
|
||||
powerful
|
||||
precious
|
||||
precise
|
||||
premium
|
||||
prepared
|
||||
present
|
||||
pretty
|
||||
primary
|
||||
prime
|
||||
pro
|
||||
probable
|
||||
profound
|
||||
promoted
|
||||
prompt
|
||||
proper
|
||||
proud
|
||||
proven
|
||||
pumped
|
||||
pure
|
||||
quality
|
||||
quick
|
||||
quiet
|
||||
rapid
|
||||
rare
|
||||
rational
|
||||
ready
|
||||
real
|
||||
refined
|
||||
regular
|
||||
related
|
||||
relative
|
||||
relaxed
|
||||
relaxing
|
||||
relevant
|
||||
relieved
|
||||
renewed
|
||||
renewing
|
||||
resolved
|
||||
rested
|
||||
rich
|
||||
right
|
||||
robust
|
||||
romantic
|
||||
ruling
|
||||
sacred
|
||||
safe
|
||||
saved
|
||||
saving
|
||||
secure
|
||||
select
|
||||
selected
|
||||
sensible
|
||||
set
|
||||
settled
|
||||
settling
|
||||
sharing
|
||||
sharp
|
||||
shining
|
||||
simple
|
||||
sincere
|
||||
singular
|
||||
skilled
|
||||
smart
|
||||
smashing
|
||||
smiling
|
||||
smooth
|
||||
social
|
||||
solid
|
||||
sought
|
||||
sound
|
||||
special
|
||||
splendid
|
||||
square
|
||||
stable
|
||||
star
|
||||
steady
|
||||
sterling
|
||||
still
|
||||
stirred
|
||||
stirring
|
||||
striking
|
||||
strong
|
||||
stunning
|
||||
subtle
|
||||
suitable
|
||||
suited
|
||||
summary
|
||||
sunny
|
||||
super
|
||||
superb
|
||||
supreme
|
||||
sure
|
||||
sweeping
|
||||
sweet
|
||||
talented
|
||||
teaching
|
||||
tender
|
||||
thankful
|
||||
thorough
|
||||
tidy
|
||||
tight
|
||||
together
|
||||
tolerant
|
||||
top
|
||||
topical
|
||||
tops
|
||||
touched
|
||||
touching
|
||||
tough
|
||||
true
|
||||
trusted
|
||||
trusting
|
||||
trusty
|
||||
ultimate
|
||||
unbiased
|
||||
uncommon
|
||||
unified
|
||||
unique
|
||||
united
|
||||
up
|
||||
upright
|
||||
upward
|
||||
usable
|
||||
useful
|
||||
valid
|
||||
valued
|
||||
vast
|
||||
verified
|
||||
viable
|
||||
vital
|
||||
vocal
|
||||
wanted
|
||||
warm
|
||||
wealthy
|
||||
welcome
|
||||
welcomed
|
||||
well
|
||||
whole
|
||||
willing
|
||||
winning
|
||||
wired
|
||||
wise
|
||||
witty
|
||||
wondrous
|
||||
workable
|
||||
working
|
||||
worthy
|
||||
@@ -1,452 +0,0 @@
|
||||
ox
|
||||
ant
|
||||
ape
|
||||
asp
|
||||
bat
|
||||
bee
|
||||
boa
|
||||
bug
|
||||
cat
|
||||
cod
|
||||
cow
|
||||
cub
|
||||
doe
|
||||
dog
|
||||
eel
|
||||
eft
|
||||
elf
|
||||
elk
|
||||
emu
|
||||
ewe
|
||||
fly
|
||||
fox
|
||||
gar
|
||||
gnu
|
||||
hen
|
||||
hog
|
||||
imp
|
||||
jay
|
||||
kid
|
||||
kit
|
||||
koi
|
||||
lab
|
||||
man
|
||||
owl
|
||||
pig
|
||||
pug
|
||||
pup
|
||||
ram
|
||||
rat
|
||||
ray
|
||||
yak
|
||||
bass
|
||||
bear
|
||||
bird
|
||||
boar
|
||||
buck
|
||||
bull
|
||||
calf
|
||||
chow
|
||||
clam
|
||||
colt
|
||||
crab
|
||||
crow
|
||||
dane
|
||||
deer
|
||||
dodo
|
||||
dory
|
||||
dove
|
||||
drum
|
||||
duck
|
||||
fawn
|
||||
fish
|
||||
flea
|
||||
foal
|
||||
fowl
|
||||
frog
|
||||
gnat
|
||||
goat
|
||||
grub
|
||||
gull
|
||||
hare
|
||||
hawk
|
||||
ibex
|
||||
joey
|
||||
kite
|
||||
kiwi
|
||||
lamb
|
||||
lark
|
||||
lion
|
||||
loon
|
||||
lynx
|
||||
mako
|
||||
mink
|
||||
mite
|
||||
mole
|
||||
moth
|
||||
mule
|
||||
mutt
|
||||
newt
|
||||
orca
|
||||
oryx
|
||||
pika
|
||||
pony
|
||||
puma
|
||||
seal
|
||||
shad
|
||||
slug
|
||||
sole
|
||||
stag
|
||||
stud
|
||||
swan
|
||||
tahr
|
||||
teal
|
||||
tick
|
||||
toad
|
||||
tuna
|
||||
wasp
|
||||
wolf
|
||||
worm
|
||||
wren
|
||||
yeti
|
||||
adder
|
||||
akita
|
||||
alien
|
||||
aphid
|
||||
bison
|
||||
boxer
|
||||
bream
|
||||
bunny
|
||||
burro
|
||||
camel
|
||||
chimp
|
||||
civet
|
||||
cobra
|
||||
coral
|
||||
corgi
|
||||
crane
|
||||
dingo
|
||||
drake
|
||||
eagle
|
||||
egret
|
||||
filly
|
||||
finch
|
||||
gator
|
||||
gecko
|
||||
ghost
|
||||
ghoul
|
||||
goose
|
||||
guppy
|
||||
heron
|
||||
hippo
|
||||
horse
|
||||
hound
|
||||
husky
|
||||
hyena
|
||||
koala
|
||||
krill
|
||||
leech
|
||||
lemur
|
||||
liger
|
||||
llama
|
||||
louse
|
||||
macaw
|
||||
midge
|
||||
molly
|
||||
moose
|
||||
moray
|
||||
mouse
|
||||
panda
|
||||
perch
|
||||
prawn
|
||||
quail
|
||||
racer
|
||||
raven
|
||||
rhino
|
||||
robin
|
||||
satyr
|
||||
shark
|
||||
sheep
|
||||
shrew
|
||||
skink
|
||||
skunk
|
||||
sloth
|
||||
snail
|
||||
snake
|
||||
snipe
|
||||
squid
|
||||
stork
|
||||
swift
|
||||
tapir
|
||||
tetra
|
||||
tiger
|
||||
troll
|
||||
trout
|
||||
viper
|
||||
wahoo
|
||||
whale
|
||||
zebra
|
||||
alpaca
|
||||
amoeba
|
||||
baboon
|
||||
badger
|
||||
beagle
|
||||
bedbug
|
||||
beetle
|
||||
bengal
|
||||
bobcat
|
||||
caiman
|
||||
cattle
|
||||
cicada
|
||||
collie
|
||||
condor
|
||||
cougar
|
||||
coyote
|
||||
dassie
|
||||
dragon
|
||||
earwig
|
||||
falcon
|
||||
feline
|
||||
ferret
|
||||
gannet
|
||||
gibbon
|
||||
glider
|
||||
goblin
|
||||
gopher
|
||||
grouse
|
||||
guinea
|
||||
hermit
|
||||
hornet
|
||||
iguana
|
||||
impala
|
||||
insect
|
||||
jackal
|
||||
jaguar
|
||||
jennet
|
||||
kitten
|
||||
kodiak
|
||||
lizard
|
||||
locust
|
||||
maggot
|
||||
magpie
|
||||
mammal
|
||||
mantis
|
||||
marlin
|
||||
marmot
|
||||
marten
|
||||
martin
|
||||
mayfly
|
||||
minnow
|
||||
monkey
|
||||
mullet
|
||||
muskox
|
||||
ocelot
|
||||
oriole
|
||||
osprey
|
||||
oyster
|
||||
parrot
|
||||
pigeon
|
||||
piglet
|
||||
poodle
|
||||
possum
|
||||
python
|
||||
quagga
|
||||
rabbit
|
||||
raptor
|
||||
rodent
|
||||
roughy
|
||||
salmon
|
||||
sawfly
|
||||
serval
|
||||
shiner
|
||||
shrimp
|
||||
spider
|
||||
sponge
|
||||
tarpon
|
||||
thrush
|
||||
tomcat
|
||||
toucan
|
||||
turkey
|
||||
turtle
|
||||
urchin
|
||||
vervet
|
||||
walrus
|
||||
weasel
|
||||
weevil
|
||||
wombat
|
||||
anchovy
|
||||
anemone
|
||||
bluejay
|
||||
buffalo
|
||||
bulldog
|
||||
buzzard
|
||||
caribou
|
||||
catfish
|
||||
chamois
|
||||
cheetah
|
||||
chicken
|
||||
chigger
|
||||
cowbird
|
||||
crappie
|
||||
crawdad
|
||||
cricket
|
||||
dogfish
|
||||
dolphin
|
||||
firefly
|
||||
garfish
|
||||
gazelle
|
||||
gelding
|
||||
giraffe
|
||||
gobbler
|
||||
gorilla
|
||||
goshawk
|
||||
grackle
|
||||
griffon
|
||||
grizzly
|
||||
grouper
|
||||
haddock
|
||||
hagfish
|
||||
halibut
|
||||
hamster
|
||||
herring
|
||||
javelin
|
||||
jawfish
|
||||
jaybird
|
||||
katydid
|
||||
ladybug
|
||||
lamprey
|
||||
lemming
|
||||
leopard
|
||||
lioness
|
||||
lobster
|
||||
macaque
|
||||
mallard
|
||||
mammoth
|
||||
manatee
|
||||
mastiff
|
||||
meerkat
|
||||
mollusk
|
||||
monarch
|
||||
mongrel
|
||||
monitor
|
||||
monster
|
||||
mudfish
|
||||
muskrat
|
||||
mustang
|
||||
narwhal
|
||||
oarfish
|
||||
octopus
|
||||
opossum
|
||||
ostrich
|
||||
panther
|
||||
peacock
|
||||
pegasus
|
||||
pelican
|
||||
penguin
|
||||
phoenix
|
||||
piranha
|
||||
polecat
|
||||
primate
|
||||
quetzal
|
||||
raccoon
|
||||
rattler
|
||||
redbird
|
||||
redfish
|
||||
reptile
|
||||
rooster
|
||||
sawfish
|
||||
sculpin
|
||||
seagull
|
||||
skylark
|
||||
snapper
|
||||
spaniel
|
||||
sparrow
|
||||
sunbeam
|
||||
sunbird
|
||||
sunfish
|
||||
tadpole
|
||||
terrier
|
||||
unicorn
|
||||
vulture
|
||||
wallaby
|
||||
walleye
|
||||
warthog
|
||||
whippet
|
||||
wildcat
|
||||
aardvark
|
||||
airedale
|
||||
albacore
|
||||
anteater
|
||||
antelope
|
||||
arachnid
|
||||
barnacle
|
||||
basilisk
|
||||
blowfish
|
||||
bluebird
|
||||
bluegill
|
||||
bonefish
|
||||
bullfrog
|
||||
cardinal
|
||||
chipmunk
|
||||
cockatoo
|
||||
crayfish
|
||||
dinosaur
|
||||
doberman
|
||||
duckling
|
||||
elephant
|
||||
escargot
|
||||
flamingo
|
||||
flounder
|
||||
foxhound
|
||||
glowworm
|
||||
goldfish
|
||||
grubworm
|
||||
hedgehog
|
||||
honeybee
|
||||
hookworm
|
||||
humpback
|
||||
kangaroo
|
||||
killdeer
|
||||
kingfish
|
||||
labrador
|
||||
lacewing
|
||||
ladybird
|
||||
lionfish
|
||||
longhorn
|
||||
mackerel
|
||||
malamute
|
||||
marmoset
|
||||
mastodon
|
||||
moccasin
|
||||
mongoose
|
||||
monkfish
|
||||
mosquito
|
||||
pangolin
|
||||
parakeet
|
||||
pheasant
|
||||
pipefish
|
||||
platypus
|
||||
polliwog
|
||||
porpoise
|
||||
reindeer
|
||||
ringtail
|
||||
sailfish
|
||||
scorpion
|
||||
seahorse
|
||||
seasnail
|
||||
sheepdog
|
||||
shepherd
|
||||
silkworm
|
||||
squirrel
|
||||
stallion
|
||||
starfish
|
||||
starling
|
||||
stingray
|
||||
stinkbug
|
||||
sturgeon
|
||||
terrapin
|
||||
titmouse
|
||||
tortoise
|
||||
treefrog
|
||||
werewolf
|
||||
woodcock
|
||||
@@ -1,33 +0,0 @@
|
||||
package randname
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: Make generator be based on fantasy, sci-fi and other literature
|
||||
// and artistic names.
|
||||
|
||||
//go:embed adjectives.txt
|
||||
var adjectives string
|
||||
|
||||
//go:embed names.txt
|
||||
var names string
|
||||
|
||||
var (
|
||||
adjectivesList = strings.Split(adjectives, "\n")
|
||||
namesList = strings.Split(names, "\n")
|
||||
)
|
||||
|
||||
func New(sep ...string) string {
|
||||
if len(sep) == 0 {
|
||||
sep = append(sep, " ")
|
||||
}
|
||||
|
||||
a := adjectivesList[rand.Intn(len(adjectivesList))]
|
||||
n := namesList[rand.Intn(len(namesList))]
|
||||
|
||||
return fmt.Sprintf("%s%s%s", a, sep[0], n)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor"
|
||||
"code.capytal.cc/capytal/comicverse/editor/internals/randname"
|
||||
"code.capytal.cc/loreddev/smalltrip/problem"
|
||||
"code.capytal.cc/loreddev/x/xtemplate"
|
||||
)
|
||||
|
||||
type dashboardController struct {
|
||||
editor *editor.Editor
|
||||
templater xtemplate.Templater
|
||||
}
|
||||
|
||||
func (ctrl *dashboardController) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
randtitle := randname.New()
|
||||
|
||||
err := ctrl.templater.ExecuteTemplate(w, "editor-dashboard", map[string]any{
|
||||
"RandTitle": randtitle,
|
||||
})
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor"
|
||||
"code.capytal.cc/capytal/comicverse/editor/internals/randname"
|
||||
"code.capytal.cc/loreddev/smalltrip/problem"
|
||||
"code.capytal.cc/loreddev/x/xtemplate"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type publicationController struct {
|
||||
editor *editor.Editor
|
||||
templater xtemplate.Templater
|
||||
}
|
||||
|
||||
func (ctrl *publicationController) createPublication(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.FormValue("title")
|
||||
if title == "" {
|
||||
title = randname.New()
|
||||
}
|
||||
|
||||
lang := language.English
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = ctrl.editor.New(id, title, lang)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("./%s", id), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (ctrl *publicationController) getPublication(w http.ResponseWriter, r *http.Request) {
|
||||
idstr := r.PathValue("publicationID")
|
||||
if idstr == "" {
|
||||
problem.NewBadRequest("Missing publication ID in path").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(idstr)
|
||||
if err != nil {
|
||||
problem.NewBadRequest("Invalid UUID in path", problem.WithError(err)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := ctrl.editor.Open(id)
|
||||
if errors.Is(err, editor.ErrNotExists) {
|
||||
problem.NewNotFound().ServeHTTP(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
w.Write(fmt.Appendf([]byte{}, "%+v", pkg))
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor"
|
||||
"code.capytal.cc/loreddev/smalltrip"
|
||||
"code.capytal.cc/loreddev/smalltrip/middleware"
|
||||
"code.capytal.cc/loreddev/smalltrip/multiplexer"
|
||||
"code.capytal.cc/loreddev/x/xtemplate"
|
||||
)
|
||||
|
||||
func New(cfg Config) http.Handler {
|
||||
log := cfg.Logger
|
||||
|
||||
mux := multiplexer.New()
|
||||
mux = multiplexer.WithFormMethod(mux, "x-method")
|
||||
mux = multiplexer.WithPatternRules(mux,
|
||||
multiplexer.EnsureMethod(),
|
||||
multiplexer.EnsureTrailingSlash(),
|
||||
multiplexer.EnsureStrictEnd(),
|
||||
)
|
||||
|
||||
r := smalltrip.NewRouter(
|
||||
smalltrip.WithMultiplexer(mux),
|
||||
smalltrip.WithLogger(log.WithGroup("router")),
|
||||
)
|
||||
|
||||
r.Use(middleware.Logger(log.WithGroup("requests")))
|
||||
// r.Use(problem.Middleware(problem.DefaultHandler))
|
||||
|
||||
r.Handle("GET /assets/{asset...}", http.StripPrefix("/assets/", http.FileServerFS(cfg.Assets)))
|
||||
|
||||
dashboardCtrl := &dashboardController{editor: cfg.Editor, templater: cfg.Templater}
|
||||
publicationCtrl := &publicationController{editor: cfg.Editor, templater: cfg.Templater}
|
||||
|
||||
r.HandleFunc("GET /{$}", dashboardCtrl.dashboard)
|
||||
|
||||
r.HandleFunc("POST /publication/{$}", publicationCtrl.createPublication)
|
||||
r.HandleFunc("GET /publication/{publicationID}/{$}", publicationCtrl.getPublication)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Assets fs.FS
|
||||
Editor *editor.Editor
|
||||
Templater xtemplate.Templater
|
||||
Logger *slog.Logger
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
func Newlocal(
|
||||
root *os.Root,
|
||||
logger *slog.Logger,
|
||||
) Storage {
|
||||
return &local{
|
||||
log: logger,
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
type local struct {
|
||||
log *slog.Logger
|
||||
root *os.Root
|
||||
}
|
||||
|
||||
var _ Storage = (*local)(nil)
|
||||
|
||||
func (files *local) Exists(p string) bool {
|
||||
if _, err := files.root.Stat(p); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (files *local) Open(p string) (fs.File, error) {
|
||||
log := files.log.With(
|
||||
slog.String("path", p),
|
||||
slog.String("root", files.root.Name()))
|
||||
|
||||
log.Debug("Opening file")
|
||||
defer log.Debug("File opened")
|
||||
|
||||
f, err := files.root.Open(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotExists
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (files *local) Write(p string, d []byte) (int, error) {
|
||||
log := files.log.With(
|
||||
slog.String("path", p),
|
||||
slog.String("root", files.root.Name()))
|
||||
|
||||
log.Debug("Writing file")
|
||||
defer log.Debug("File wrote")
|
||||
|
||||
if err := files.root.MkdirAll(path.Dir(p), os.ModePerm); err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to create parent directories %q: %w", path.Dir(p), err)
|
||||
}
|
||||
|
||||
err := files.root.WriteFile(p, d, os.ModePerm)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to write file %q: %w", p, err)
|
||||
}
|
||||
|
||||
return len(d), nil
|
||||
}
|
||||
|
||||
func (files *local) WriteFrom(p string, r io.Reader) (int64, error) {
|
||||
log := files.log.With(
|
||||
slog.String("path", p),
|
||||
slog.String("root", files.root.Name()))
|
||||
|
||||
log.Debug("Writing file")
|
||||
defer log.Debug("File wrote")
|
||||
|
||||
if err := files.root.MkdirAll(path.Dir(p), os.ModePerm); err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to create parent directories %q: %w", path.Dir(p), err)
|
||||
}
|
||||
|
||||
f, err := files.root.Create(p)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to create file %q: %w", p, err)
|
||||
}
|
||||
|
||||
n, err := f.ReadFrom(r)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to write file %q: %w", p, err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
Exists(p string) bool
|
||||
Open(p string) (fs.File, error)
|
||||
Write(p string, b []byte) (int, error)
|
||||
WriteFrom(p string, r io.Reader) (int64, error)
|
||||
}
|
||||
|
||||
type withRoot struct {
|
||||
root string
|
||||
Storage
|
||||
}
|
||||
|
||||
func WithRoot(rootDir string, s Storage) Storage {
|
||||
return &withRoot{root: rootDir, Storage: s}
|
||||
}
|
||||
|
||||
func (f *withRoot) Exists(p string) bool {
|
||||
return f.Storage.Exists(path.Join(f.root, p))
|
||||
}
|
||||
|
||||
func (f *withRoot) Open(p string) (fs.File, error) {
|
||||
return f.Storage.Open(path.Join(f.root, p))
|
||||
}
|
||||
|
||||
func (f *withRoot) Write(p string, b []byte) (int, error) {
|
||||
return f.Storage.Write(path.Join(f.root, p), b)
|
||||
}
|
||||
|
||||
func (f *withRoot) WriteFrom(p string, r io.Reader) (int64, error) {
|
||||
return f.Storage.WriteFrom(path.Join(f.root, p), r)
|
||||
}
|
||||
|
||||
var ErrNotExists = os.ErrNotExist
|
||||
@@ -1,31 +0,0 @@
|
||||
{{define "editor-dashboard"}} {{template "layout-base"}}
|
||||
<body class="bg-gray-900 text-gray-50 has-[#first-publication]:h-svw">
|
||||
<main class="has-[#first-publication]:h-full flex flex-col">
|
||||
{{if .Publications}}
|
||||
<p>Publications</p>
|
||||
{{else}}
|
||||
<h1>Create your first publication</h1>
|
||||
<form method="post" action="/publication/" id="first-publication">
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value="{{if .RandTitle}}{{.RandTitle}}{{end}}"
|
||||
/><button type="submit">Create</button>
|
||||
</form>
|
||||
<style>
|
||||
body:has(:is(#first-publication)) {
|
||||
height: 100svh;
|
||||
& > main {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
{{template "layout-base-end"}} {{end}}
|
||||
@@ -1,19 +0,0 @@
|
||||
{{define "layout-base"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{{if .Title}}
|
||||
<title>{{.Title}}</title>
|
||||
{{end}}
|
||||
<link href="/assets/css/style.css" rel="stylesheet" />
|
||||
<script
|
||||
src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.js"
|
||||
integrity="sha384-oeUn82QNXPuVkGCkcrInrS1twIxKhkZiFfr2TdiuObZ3n3yIeMiqcRzkIcguaof1"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
</head>
|
||||
{{end}} {{define "layout-base-end"}}
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,50 +0,0 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
|
||||
"code.capytal.cc/loreddev/x/xtemplate"
|
||||
)
|
||||
|
||||
func New() (xtemplate.Template, error) {
|
||||
return xtemplate.New[template.Template]("template").
|
||||
Funcs(functions).
|
||||
ParseFS(embedded, patterns...)
|
||||
}
|
||||
|
||||
//go:embed *.html layouts/*.html
|
||||
var embedded embed.FS
|
||||
|
||||
func Dev(dir fs.FS) (xtemplate.Template, error) {
|
||||
return xtemplate.NewHot[template.Template]("template").
|
||||
Funcs(functions).
|
||||
ParseFS(dir, patterns...)
|
||||
}
|
||||
|
||||
var (
|
||||
patterns = []string{"*.html", "layouts/*.html"}
|
||||
functions = template.FuncMap{
|
||||
"args": func(pairs ...any) (map[string]any, error) {
|
||||
if len(pairs)%2 != 0 {
|
||||
return nil, errors.New("misaligned map in template arguments")
|
||||
}
|
||||
|
||||
m := make(map[string]any, len(pairs)/2)
|
||||
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
key, ok := pairs[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot use type %T as map key", pairs[i])
|
||||
}
|
||||
|
||||
m[key] = pairs[i+1]
|
||||
}
|
||||
|
||||
return m, nil
|
||||
},
|
||||
}
|
||||
)
|
||||
33
eslint.config.js
Normal file
33
eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||
}
|
||||
];
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1762844143,
|
||||
"narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=",
|
||||
"lastModified": 1726243404,
|
||||
"narHash": "sha256-sjiGsMh+1cWXb53Tecsm4skyFNag33GPbVgCdfj3n9I=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4",
|
||||
"rev": "345c263f2f53a3710abe117f28a5cb86d0ba4059",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
44
flake.nix
44
flake.nix
@@ -3,7 +3,10 @@
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
};
|
||||
outputs = {nixpkgs, ...}: let
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
}: let
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
@@ -18,41 +21,12 @@
|
||||
in {
|
||||
devShells = forAllSystems (system: pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
CGO_ENABLED = "1";
|
||||
hardeningDisable = ["fortify"];
|
||||
|
||||
GOPRIVATE = "code.capytal.cc/*";
|
||||
|
||||
shellHook = ''
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
'';
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
# Go tools
|
||||
go_1_25
|
||||
golangci-lint
|
||||
gofumpt
|
||||
gotools
|
||||
delve
|
||||
|
||||
# TailwindCSS
|
||||
tailwindcss_4
|
||||
|
||||
# Sqlite tools
|
||||
sqlite
|
||||
lazysql
|
||||
litecli
|
||||
|
||||
# S3
|
||||
awscli
|
||||
|
||||
# ePUB
|
||||
http-server
|
||||
calibre
|
||||
zip
|
||||
unzip
|
||||
awscli2
|
||||
bun
|
||||
eslint
|
||||
nodejs_22
|
||||
nodePackages_latest.prettier
|
||||
];
|
||||
};
|
||||
});
|
||||
|
||||
30
go.mod
30
go.mod
@@ -1,30 +0,0 @@
|
||||
module code.capytal.cc/capytal/comicverse
|
||||
|
||||
go 1.25.2
|
||||
|
||||
require (
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251013182121-3d201d21226c
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251013175605-6ea200aa6442
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92
|
||||
golang.org/x/crypto v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||
github.com/aws/smithy-go v1.22.2 // indirect
|
||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
)
|
||||
46
go.sum
46
go.sum
@@ -1,46 +0,0 @@
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251013182121-3d201d21226c h1:Ith3zqoEl0o8mCFdzBemk/8YgVfEaNPYFsbpu/hssAE=
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251013182121-3d201d21226c/go.mod h1:CjzhmbQIf4PlnsCF5gK/5e4qDP7JeT+7CcVvbx+DtUg=
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251013175605-6ea200aa6442 h1:YyfSJhrDz9PLf5snD5gV+T8dvBmDlXFkT8tx8p5l6K4=
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251013175605-6ea200aa6442/go.mod h1:o9HsngwSWEAETuvFoOqlKj431Ri3cOL0g8Li2M49DAo=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16NPqZFieEhLboSU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
|
||||
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
||||
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
16
go.work.sum
16
go.work.sum
@@ -1,16 +0,0 @@
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
@@ -1,25 +0,0 @@
|
||||
package joinedfs
|
||||
|
||||
import "io/fs"
|
||||
|
||||
func Join(fsys ...fs.FS) fs.FS {
|
||||
return &joinedFS{fsys}
|
||||
}
|
||||
|
||||
type joinedFS struct {
|
||||
fsys []fs.FS
|
||||
}
|
||||
|
||||
var _ fs.FS = (*joinedFS)(nil)
|
||||
|
||||
func (j *joinedFS) Open(name string) (fs.File, error) {
|
||||
var err error
|
||||
var f fs.File
|
||||
for _, fsys := range j.fsys {
|
||||
f, err = fsys.Open(name)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
return f, err
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
Adjectives and names list files were copied from Dustin Kirkland's <dustin.kirkland@gmail.com>
|
||||
petname project at Github, specifically from these files:
|
||||
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/adjectives.txt
|
||||
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/names.txt
|
||||
|
||||
The original files are provided and released under the Apache License version 2,
|
||||
which a copy is available at
|
||||
https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/LICENSE
|
||||
@@ -1,452 +0,0 @@
|
||||
ox
|
||||
ant
|
||||
ape
|
||||
asp
|
||||
bat
|
||||
bee
|
||||
boa
|
||||
bug
|
||||
cat
|
||||
cod
|
||||
cow
|
||||
cub
|
||||
doe
|
||||
dog
|
||||
eel
|
||||
eft
|
||||
elf
|
||||
elk
|
||||
emu
|
||||
ewe
|
||||
fly
|
||||
fox
|
||||
gar
|
||||
gnu
|
||||
hen
|
||||
hog
|
||||
imp
|
||||
jay
|
||||
kid
|
||||
kit
|
||||
koi
|
||||
lab
|
||||
man
|
||||
owl
|
||||
pig
|
||||
pug
|
||||
pup
|
||||
ram
|
||||
rat
|
||||
ray
|
||||
yak
|
||||
bass
|
||||
bear
|
||||
bird
|
||||
boar
|
||||
buck
|
||||
bull
|
||||
calf
|
||||
chow
|
||||
clam
|
||||
colt
|
||||
crab
|
||||
crow
|
||||
dane
|
||||
deer
|
||||
dodo
|
||||
dory
|
||||
dove
|
||||
drum
|
||||
duck
|
||||
fawn
|
||||
fish
|
||||
flea
|
||||
foal
|
||||
fowl
|
||||
frog
|
||||
gnat
|
||||
goat
|
||||
grub
|
||||
gull
|
||||
hare
|
||||
hawk
|
||||
ibex
|
||||
joey
|
||||
kite
|
||||
kiwi
|
||||
lamb
|
||||
lark
|
||||
lion
|
||||
loon
|
||||
lynx
|
||||
mako
|
||||
mink
|
||||
mite
|
||||
mole
|
||||
moth
|
||||
mule
|
||||
mutt
|
||||
newt
|
||||
orca
|
||||
oryx
|
||||
pika
|
||||
pony
|
||||
puma
|
||||
seal
|
||||
shad
|
||||
slug
|
||||
sole
|
||||
stag
|
||||
stud
|
||||
swan
|
||||
tahr
|
||||
teal
|
||||
tick
|
||||
toad
|
||||
tuna
|
||||
wasp
|
||||
wolf
|
||||
worm
|
||||
wren
|
||||
yeti
|
||||
adder
|
||||
akita
|
||||
alien
|
||||
aphid
|
||||
bison
|
||||
boxer
|
||||
bream
|
||||
bunny
|
||||
burro
|
||||
camel
|
||||
chimp
|
||||
civet
|
||||
cobra
|
||||
coral
|
||||
corgi
|
||||
crane
|
||||
dingo
|
||||
drake
|
||||
eagle
|
||||
egret
|
||||
filly
|
||||
finch
|
||||
gator
|
||||
gecko
|
||||
ghost
|
||||
ghoul
|
||||
goose
|
||||
guppy
|
||||
heron
|
||||
hippo
|
||||
horse
|
||||
hound
|
||||
husky
|
||||
hyena
|
||||
koala
|
||||
krill
|
||||
leech
|
||||
lemur
|
||||
liger
|
||||
llama
|
||||
louse
|
||||
macaw
|
||||
midge
|
||||
molly
|
||||
moose
|
||||
moray
|
||||
mouse
|
||||
panda
|
||||
perch
|
||||
prawn
|
||||
quail
|
||||
racer
|
||||
raven
|
||||
rhino
|
||||
robin
|
||||
satyr
|
||||
shark
|
||||
sheep
|
||||
shrew
|
||||
skink
|
||||
skunk
|
||||
sloth
|
||||
snail
|
||||
snake
|
||||
snipe
|
||||
squid
|
||||
stork
|
||||
swift
|
||||
tapir
|
||||
tetra
|
||||
tiger
|
||||
troll
|
||||
trout
|
||||
viper
|
||||
wahoo
|
||||
whale
|
||||
zebra
|
||||
alpaca
|
||||
amoeba
|
||||
baboon
|
||||
badger
|
||||
beagle
|
||||
bedbug
|
||||
beetle
|
||||
bengal
|
||||
bobcat
|
||||
caiman
|
||||
cattle
|
||||
cicada
|
||||
collie
|
||||
condor
|
||||
cougar
|
||||
coyote
|
||||
dassie
|
||||
dragon
|
||||
earwig
|
||||
falcon
|
||||
feline
|
||||
ferret
|
||||
gannet
|
||||
gibbon
|
||||
glider
|
||||
goblin
|
||||
gopher
|
||||
grouse
|
||||
guinea
|
||||
hermit
|
||||
hornet
|
||||
iguana
|
||||
impala
|
||||
insect
|
||||
jackal
|
||||
jaguar
|
||||
jennet
|
||||
kitten
|
||||
kodiak
|
||||
lizard
|
||||
locust
|
||||
maggot
|
||||
magpie
|
||||
mammal
|
||||
mantis
|
||||
marlin
|
||||
marmot
|
||||
marten
|
||||
martin
|
||||
mayfly
|
||||
minnow
|
||||
monkey
|
||||
mullet
|
||||
muskox
|
||||
ocelot
|
||||
oriole
|
||||
osprey
|
||||
oyster
|
||||
parrot
|
||||
pigeon
|
||||
piglet
|
||||
poodle
|
||||
possum
|
||||
python
|
||||
quagga
|
||||
rabbit
|
||||
raptor
|
||||
rodent
|
||||
roughy
|
||||
salmon
|
||||
sawfly
|
||||
serval
|
||||
shiner
|
||||
shrimp
|
||||
spider
|
||||
sponge
|
||||
tarpon
|
||||
thrush
|
||||
tomcat
|
||||
toucan
|
||||
turkey
|
||||
turtle
|
||||
urchin
|
||||
vervet
|
||||
walrus
|
||||
weasel
|
||||
weevil
|
||||
wombat
|
||||
anchovy
|
||||
anemone
|
||||
bluejay
|
||||
buffalo
|
||||
bulldog
|
||||
buzzard
|
||||
caribou
|
||||
catfish
|
||||
chamois
|
||||
cheetah
|
||||
chicken
|
||||
chigger
|
||||
cowbird
|
||||
crappie
|
||||
crawdad
|
||||
cricket
|
||||
dogfish
|
||||
dolphin
|
||||
firefly
|
||||
garfish
|
||||
gazelle
|
||||
gelding
|
||||
giraffe
|
||||
gobbler
|
||||
gorilla
|
||||
goshawk
|
||||
grackle
|
||||
griffon
|
||||
grizzly
|
||||
grouper
|
||||
haddock
|
||||
hagfish
|
||||
halibut
|
||||
hamster
|
||||
herring
|
||||
javelin
|
||||
jawfish
|
||||
jaybird
|
||||
katydid
|
||||
ladybug
|
||||
lamprey
|
||||
lemming
|
||||
leopard
|
||||
lioness
|
||||
lobster
|
||||
macaque
|
||||
mallard
|
||||
mammoth
|
||||
manatee
|
||||
mastiff
|
||||
meerkat
|
||||
mollusk
|
||||
monarch
|
||||
mongrel
|
||||
monitor
|
||||
monster
|
||||
mudfish
|
||||
muskrat
|
||||
mustang
|
||||
narwhal
|
||||
oarfish
|
||||
octopus
|
||||
opossum
|
||||
ostrich
|
||||
panther
|
||||
peacock
|
||||
pegasus
|
||||
pelican
|
||||
penguin
|
||||
phoenix
|
||||
piranha
|
||||
polecat
|
||||
primate
|
||||
quetzal
|
||||
raccoon
|
||||
rattler
|
||||
redbird
|
||||
redfish
|
||||
reptile
|
||||
rooster
|
||||
sawfish
|
||||
sculpin
|
||||
seagull
|
||||
skylark
|
||||
snapper
|
||||
spaniel
|
||||
sparrow
|
||||
sunbeam
|
||||
sunbird
|
||||
sunfish
|
||||
tadpole
|
||||
terrier
|
||||
unicorn
|
||||
vulture
|
||||
wallaby
|
||||
walleye
|
||||
warthog
|
||||
whippet
|
||||
wildcat
|
||||
aardvark
|
||||
airedale
|
||||
albacore
|
||||
anteater
|
||||
antelope
|
||||
arachnid
|
||||
barnacle
|
||||
basilisk
|
||||
blowfish
|
||||
bluebird
|
||||
bluegill
|
||||
bonefish
|
||||
bullfrog
|
||||
cardinal
|
||||
chipmunk
|
||||
cockatoo
|
||||
crayfish
|
||||
dinosaur
|
||||
doberman
|
||||
duckling
|
||||
elephant
|
||||
escargot
|
||||
flamingo
|
||||
flounder
|
||||
foxhound
|
||||
glowworm
|
||||
goldfish
|
||||
grubworm
|
||||
hedgehog
|
||||
honeybee
|
||||
hookworm
|
||||
humpback
|
||||
kangaroo
|
||||
killdeer
|
||||
kingfish
|
||||
labrador
|
||||
lacewing
|
||||
ladybird
|
||||
lionfish
|
||||
longhorn
|
||||
mackerel
|
||||
malamute
|
||||
marmoset
|
||||
mastodon
|
||||
moccasin
|
||||
mongoose
|
||||
monkfish
|
||||
mosquito
|
||||
pangolin
|
||||
parakeet
|
||||
pheasant
|
||||
pipefish
|
||||
platypus
|
||||
polliwog
|
||||
porpoise
|
||||
reindeer
|
||||
ringtail
|
||||
sailfish
|
||||
scorpion
|
||||
seahorse
|
||||
seasnail
|
||||
sheepdog
|
||||
shepherd
|
||||
silkworm
|
||||
squirrel
|
||||
stallion
|
||||
starfish
|
||||
starling
|
||||
stingray
|
||||
stinkbug
|
||||
sturgeon
|
||||
terrapin
|
||||
titmouse
|
||||
tortoise
|
||||
treefrog
|
||||
werewolf
|
||||
woodcock
|
||||
@@ -1,449 +0,0 @@
|
||||
able
|
||||
above
|
||||
absolute
|
||||
accepted
|
||||
accurate
|
||||
ace
|
||||
active
|
||||
actual
|
||||
adapted
|
||||
adapting
|
||||
adequate
|
||||
adjusted
|
||||
advanced
|
||||
alert
|
||||
alive
|
||||
allowed
|
||||
allowing
|
||||
amazed
|
||||
amazing
|
||||
ample
|
||||
amused
|
||||
amusing
|
||||
apparent
|
||||
apt
|
||||
arriving
|
||||
artistic
|
||||
assured
|
||||
assuring
|
||||
awaited
|
||||
awake
|
||||
aware
|
||||
balanced
|
||||
becoming
|
||||
beloved
|
||||
better
|
||||
big
|
||||
blessed
|
||||
bold
|
||||
boss
|
||||
brave
|
||||
brief
|
||||
bright
|
||||
bursting
|
||||
busy
|
||||
calm
|
||||
capable
|
||||
capital
|
||||
careful
|
||||
caring
|
||||
casual
|
||||
causal
|
||||
central
|
||||
certain
|
||||
champion
|
||||
charmed
|
||||
charming
|
||||
cheerful
|
||||
chief
|
||||
choice
|
||||
civil
|
||||
classic
|
||||
clean
|
||||
clear
|
||||
clever
|
||||
climbing
|
||||
close
|
||||
closing
|
||||
coherent
|
||||
comic
|
||||
communal
|
||||
complete
|
||||
composed
|
||||
concise
|
||||
concrete
|
||||
content
|
||||
cool
|
||||
correct
|
||||
cosmic
|
||||
crack
|
||||
creative
|
||||
credible
|
||||
crisp
|
||||
crucial
|
||||
cuddly
|
||||
cunning
|
||||
curious
|
||||
current
|
||||
cute
|
||||
daring
|
||||
darling
|
||||
dashing
|
||||
dear
|
||||
decent
|
||||
deciding
|
||||
deep
|
||||
definite
|
||||
delicate
|
||||
desired
|
||||
destined
|
||||
devoted
|
||||
direct
|
||||
discrete
|
||||
distinct
|
||||
diverse
|
||||
divine
|
||||
dominant
|
||||
driven
|
||||
driving
|
||||
dynamic
|
||||
eager
|
||||
easy
|
||||
electric
|
||||
elegant
|
||||
emerging
|
||||
eminent
|
||||
enabled
|
||||
enabling
|
||||
endless
|
||||
engaged
|
||||
engaging
|
||||
enhanced
|
||||
enjoyed
|
||||
enormous
|
||||
enough
|
||||
epic
|
||||
equal
|
||||
equipped
|
||||
eternal
|
||||
ethical
|
||||
evident
|
||||
evolved
|
||||
evolving
|
||||
exact
|
||||
excited
|
||||
exciting
|
||||
exotic
|
||||
expert
|
||||
factual
|
||||
fair
|
||||
faithful
|
||||
famous
|
||||
fancy
|
||||
fast
|
||||
feasible
|
||||
fine
|
||||
finer
|
||||
firm
|
||||
first
|
||||
fit
|
||||
fitting
|
||||
fleet
|
||||
flexible
|
||||
flowing
|
||||
fluent
|
||||
flying
|
||||
fond
|
||||
frank
|
||||
free
|
||||
fresh
|
||||
full
|
||||
fun
|
||||
funky
|
||||
funny
|
||||
game
|
||||
generous
|
||||
gentle
|
||||
genuine
|
||||
giving
|
||||
glad
|
||||
glorious
|
||||
glowing
|
||||
golden
|
||||
good
|
||||
gorgeous
|
||||
grand
|
||||
grateful
|
||||
great
|
||||
growing
|
||||
grown
|
||||
guided
|
||||
guiding
|
||||
handy
|
||||
happy
|
||||
hardy
|
||||
harmless
|
||||
healthy
|
||||
helped
|
||||
helpful
|
||||
helping
|
||||
heroic
|
||||
hip
|
||||
holy
|
||||
honest
|
||||
hopeful
|
||||
hot
|
||||
huge
|
||||
humane
|
||||
humble
|
||||
humorous
|
||||
ideal
|
||||
immense
|
||||
immortal
|
||||
immune
|
||||
improved
|
||||
in
|
||||
included
|
||||
infinite
|
||||
informed
|
||||
innocent
|
||||
inspired
|
||||
integral
|
||||
intense
|
||||
intent
|
||||
internal
|
||||
intimate
|
||||
inviting
|
||||
joint
|
||||
just
|
||||
keen
|
||||
key
|
||||
kind
|
||||
knowing
|
||||
known
|
||||
large
|
||||
lasting
|
||||
leading
|
||||
learning
|
||||
legal
|
||||
legible
|
||||
lenient
|
||||
liberal
|
||||
light
|
||||
liked
|
||||
literate
|
||||
live
|
||||
living
|
||||
logical
|
||||
loved
|
||||
loving
|
||||
loyal
|
||||
lucky
|
||||
magical
|
||||
magnetic
|
||||
main
|
||||
major
|
||||
many
|
||||
massive
|
||||
master
|
||||
mature
|
||||
maximum
|
||||
measured
|
||||
meet
|
||||
merry
|
||||
mighty
|
||||
mint
|
||||
model
|
||||
modern
|
||||
modest
|
||||
moral
|
||||
more
|
||||
moved
|
||||
moving
|
||||
musical
|
||||
mutual
|
||||
national
|
||||
native
|
||||
natural
|
||||
nearby
|
||||
neat
|
||||
needed
|
||||
neutral
|
||||
new
|
||||
next
|
||||
nice
|
||||
noble
|
||||
normal
|
||||
notable
|
||||
noted
|
||||
novel
|
||||
obliging
|
||||
on
|
||||
one
|
||||
open
|
||||
optimal
|
||||
optimum
|
||||
organic
|
||||
oriented
|
||||
outgoing
|
||||
patient
|
||||
peaceful
|
||||
perfect
|
||||
pet
|
||||
picked
|
||||
pleasant
|
||||
pleased
|
||||
pleasing
|
||||
poetic
|
||||
polished
|
||||
polite
|
||||
popular
|
||||
positive
|
||||
possible
|
||||
powerful
|
||||
precious
|
||||
precise
|
||||
premium
|
||||
prepared
|
||||
present
|
||||
pretty
|
||||
primary
|
||||
prime
|
||||
pro
|
||||
probable
|
||||
profound
|
||||
promoted
|
||||
prompt
|
||||
proper
|
||||
proud
|
||||
proven
|
||||
pumped
|
||||
pure
|
||||
quality
|
||||
quick
|
||||
quiet
|
||||
rapid
|
||||
rare
|
||||
rational
|
||||
ready
|
||||
real
|
||||
refined
|
||||
regular
|
||||
related
|
||||
relative
|
||||
relaxed
|
||||
relaxing
|
||||
relevant
|
||||
relieved
|
||||
renewed
|
||||
renewing
|
||||
resolved
|
||||
rested
|
||||
rich
|
||||
right
|
||||
robust
|
||||
romantic
|
||||
ruling
|
||||
sacred
|
||||
safe
|
||||
saved
|
||||
saving
|
||||
secure
|
||||
select
|
||||
selected
|
||||
sensible
|
||||
set
|
||||
settled
|
||||
settling
|
||||
sharing
|
||||
sharp
|
||||
shining
|
||||
simple
|
||||
sincere
|
||||
singular
|
||||
skilled
|
||||
smart
|
||||
smashing
|
||||
smiling
|
||||
smooth
|
||||
social
|
||||
solid
|
||||
sought
|
||||
sound
|
||||
special
|
||||
splendid
|
||||
square
|
||||
stable
|
||||
star
|
||||
steady
|
||||
sterling
|
||||
still
|
||||
stirred
|
||||
stirring
|
||||
striking
|
||||
strong
|
||||
stunning
|
||||
subtle
|
||||
suitable
|
||||
suited
|
||||
summary
|
||||
sunny
|
||||
super
|
||||
superb
|
||||
supreme
|
||||
sure
|
||||
sweeping
|
||||
sweet
|
||||
talented
|
||||
teaching
|
||||
tender
|
||||
thankful
|
||||
thorough
|
||||
tidy
|
||||
tight
|
||||
together
|
||||
tolerant
|
||||
top
|
||||
topical
|
||||
tops
|
||||
touched
|
||||
touching
|
||||
tough
|
||||
true
|
||||
trusted
|
||||
trusting
|
||||
trusty
|
||||
ultimate
|
||||
unbiased
|
||||
uncommon
|
||||
unified
|
||||
unique
|
||||
united
|
||||
up
|
||||
upright
|
||||
upward
|
||||
usable
|
||||
useful
|
||||
valid
|
||||
valued
|
||||
vast
|
||||
verified
|
||||
viable
|
||||
vital
|
||||
vocal
|
||||
wanted
|
||||
warm
|
||||
wealthy
|
||||
welcome
|
||||
welcomed
|
||||
well
|
||||
whole
|
||||
willing
|
||||
winning
|
||||
wired
|
||||
wise
|
||||
witty
|
||||
wondrous
|
||||
workable
|
||||
working
|
||||
worthy
|
||||
@@ -1,33 +0,0 @@
|
||||
package randname
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: Make generator be based on fantasy, sci-fi and other literature
|
||||
// and artistic names.
|
||||
|
||||
//go:embed adjectives.txt
|
||||
var adjectives string
|
||||
|
||||
//go:embed names.txt
|
||||
var names string
|
||||
|
||||
var (
|
||||
adjectivesList = strings.Split(adjectives, "\n")
|
||||
namesList = strings.Split(names, "\n")
|
||||
)
|
||||
|
||||
func New(sep ...string) string {
|
||||
if len(sep) == 0 {
|
||||
sep = append(sep, " ")
|
||||
}
|
||||
|
||||
a := adjectivesList[rand.Intn(len(adjectivesList))]
|
||||
n := namesList[rand.Intn(len(namesList))]
|
||||
|
||||
return fmt.Sprintf("%s%s%s", a, sep[0], n)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// This file has code copied from the "randstr" Go module, which can be found at
|
||||
// https://github.com/thanhpk/randsr. The original code is licensed under the MIT
|
||||
// license, which a copy can be found at https://github.com/thanhpk/randstr/blob/master/LICENSE
|
||||
// and is provided below:
|
||||
//
|
||||
// # The MIT License
|
||||
//
|
||||
// Copyright (c) 2010-2018 Google, Inc. http://angularjs.org
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
// Package randstr provides basic functions for generating random bytes, string
|
||||
package randstr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// HexChars holds a string containing all characters used in a hexadecimal value.
|
||||
const HexChars = "0123456789abcdef"
|
||||
|
||||
// NewHex generates a new Hexadecimal string with length of n
|
||||
//
|
||||
// Example: 67aab2d956bd7cc621af22cfb169cba8
|
||||
func NewHex(n int) (string, error) { return New(n, HexChars) }
|
||||
|
||||
// New generates a random string using only letters provided in the letters parameter.
|
||||
//
|
||||
// If the letters parameter is omitted, this function will use HexChars instead.
|
||||
func New(n int, chars ...string) (string, error) {
|
||||
runes := []rune(HexChars)
|
||||
if len(chars) > 0 {
|
||||
runes = []rune(chars[0])
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
b.Grow(n)
|
||||
l := uint32(len(runes))
|
||||
for range n {
|
||||
by, err := Bytes(4)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteRune(runes[binary.BigEndian.Uint32(by)%l])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// Bytes generates n random bytes
|
||||
func Bytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
195
ipub/ast/ast.go
195
ipub/ast/ast.go
@@ -1,195 +0,0 @@
|
||||
package ast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Node interface {
|
||||
Kind() NodeKind
|
||||
|
||||
NextSibling() Node
|
||||
SetNextSibling(Node)
|
||||
|
||||
PreviousSibling() Node
|
||||
SetPreviousSibling(Node)
|
||||
|
||||
Parent() Node
|
||||
SetParent(Node)
|
||||
|
||||
HasChildren() bool
|
||||
ChildCount() uint
|
||||
|
||||
FirstChild() Node
|
||||
LastChild() Node
|
||||
|
||||
AppendChild(self, v Node)
|
||||
RemoveChild(self, v Node)
|
||||
|
||||
RemoveChildren(self Node)
|
||||
ReplaceChild(self, v1, insertee Node)
|
||||
|
||||
InsertBefore(self, v1, insertee Node)
|
||||
InsertAfter(self, v1, insertee Node)
|
||||
}
|
||||
|
||||
type BaseNode struct {
|
||||
next Node
|
||||
prev Node
|
||||
parent Node
|
||||
fisrtChild Node
|
||||
lastChild Node
|
||||
childCount uint
|
||||
}
|
||||
|
||||
func (e *BaseNode) NextSibling() Node {
|
||||
return e.next
|
||||
}
|
||||
|
||||
func (e *BaseNode) SetNextSibling(v Node) {
|
||||
e.next = v
|
||||
}
|
||||
|
||||
func (e *BaseNode) PreviousSibling() Node {
|
||||
return e.prev
|
||||
}
|
||||
|
||||
func (e *BaseNode) SetPreviousSibling(v Node) {
|
||||
e.prev = v
|
||||
}
|
||||
|
||||
func (e *BaseNode) Parent() Node {
|
||||
return e.parent
|
||||
}
|
||||
|
||||
func (e *BaseNode) SetParent(v Node) {
|
||||
e.parent = v
|
||||
}
|
||||
|
||||
func (e *BaseNode) HasChildren() bool {
|
||||
return e.fisrtChild != nil
|
||||
}
|
||||
|
||||
func (e *BaseNode) ChildCount() uint {
|
||||
return e.childCount
|
||||
}
|
||||
|
||||
func (e *BaseNode) FirstChild() Node {
|
||||
return e.fisrtChild
|
||||
}
|
||||
|
||||
func (e *BaseNode) LastChild() Node {
|
||||
return e.lastChild
|
||||
}
|
||||
|
||||
func (e *BaseNode) AppendChild(self, v Node) {
|
||||
ensureIsolated(v)
|
||||
|
||||
if e.fisrtChild == nil {
|
||||
e.fisrtChild = v
|
||||
v.SetNextSibling(nil)
|
||||
v.SetPreviousSibling(nil)
|
||||
} else {
|
||||
l := e.lastChild
|
||||
l.SetNextSibling(v)
|
||||
v.SetPreviousSibling(l)
|
||||
}
|
||||
|
||||
v.SetParent(self)
|
||||
e.lastChild = v
|
||||
e.childCount++
|
||||
}
|
||||
|
||||
func (e *BaseNode) RemoveChild(self, v Node) {
|
||||
if v.Parent() != self {
|
||||
return
|
||||
}
|
||||
|
||||
if e.childCount <= 0 {
|
||||
e.childCount--
|
||||
}
|
||||
|
||||
prev := v.PreviousSibling()
|
||||
next := v.NextSibling()
|
||||
|
||||
if prev != nil {
|
||||
prev.SetNextSibling(next)
|
||||
} else {
|
||||
e.fisrtChild = next
|
||||
}
|
||||
|
||||
if next != nil {
|
||||
next.SetNextSibling(prev)
|
||||
} else {
|
||||
e.lastChild = prev
|
||||
}
|
||||
|
||||
v.SetParent(nil)
|
||||
v.SetNextSibling(nil)
|
||||
v.SetPreviousSibling(nil)
|
||||
}
|
||||
|
||||
func (e *BaseNode) RemoveChildren(_ Node) {
|
||||
for c := e.fisrtChild; c != nil; {
|
||||
c.SetParent(nil)
|
||||
c.SetPreviousSibling(nil)
|
||||
next := c.NextSibling()
|
||||
c.SetNextSibling(nil)
|
||||
c = next
|
||||
}
|
||||
e.fisrtChild = nil
|
||||
e.lastChild = nil
|
||||
e.childCount = 0
|
||||
}
|
||||
|
||||
func (e *BaseNode) ReplaceChild(self, v1, insertee Node) {
|
||||
e.InsertBefore(self, v1, insertee)
|
||||
e.RemoveChild(self, v1)
|
||||
}
|
||||
|
||||
func (e *BaseNode) InsertAfter(self, v1, insertee Node) {
|
||||
e.InsertBefore(self, v1.NextSibling(), insertee)
|
||||
}
|
||||
|
||||
func (e *BaseNode) InsertBefore(self, v1, insertee Node) {
|
||||
e.childCount++
|
||||
if v1 == nil {
|
||||
e.AppendChild(self, insertee)
|
||||
return
|
||||
}
|
||||
|
||||
ensureIsolated(insertee)
|
||||
|
||||
if v1.Parent() == self {
|
||||
c := v1
|
||||
prev := c.PreviousSibling()
|
||||
if prev != nil {
|
||||
prev.SetNextSibling(insertee)
|
||||
insertee.SetPreviousSibling(prev)
|
||||
} else {
|
||||
e.fisrtChild = insertee
|
||||
insertee.SetPreviousSibling(nil)
|
||||
}
|
||||
insertee.SetNextSibling(c)
|
||||
c.SetPreviousSibling(insertee)
|
||||
insertee.SetParent(self)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureIsolated(e Node) {
|
||||
if p := e.Parent(); p != nil {
|
||||
p.RemoveChild(p, e)
|
||||
}
|
||||
}
|
||||
|
||||
type NodeKind string
|
||||
|
||||
func NewNodeKind(kind string, e Node) NodeKind {
|
||||
k := NodeKind(kind)
|
||||
if _, ok := elementKindList[k]; ok {
|
||||
panic(fmt.Sprintf("Node kind %q is already registered", k))
|
||||
}
|
||||
elementKindList[k] = e
|
||||
return k
|
||||
}
|
||||
|
||||
var elementKindList = make(map[NodeKind]Node)
|
||||
@@ -1,77 +0,0 @@
|
||||
package ast_test
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/ipub/ast"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
)
|
||||
|
||||
//go:embed test.xml
|
||||
var test []byte
|
||||
|
||||
func TestMarshal(t *testing.T) {
|
||||
b := &ast.Body{}
|
||||
c := &ast.Content{}
|
||||
i := &ast.Image{}
|
||||
i.SetSource("https://hello.com/world.png")
|
||||
c.AppendChild(c, i)
|
||||
b.AppendChild(b, c)
|
||||
|
||||
s := ast.Section{
|
||||
Body: b,
|
||||
}
|
||||
by, err := xml.Marshal(s)
|
||||
|
||||
if err != nil && err != io.EOF {
|
||||
t.Error(err.Error())
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
// t.Logf("%#v", s.Body)
|
||||
//
|
||||
// t.Logf("%#v", f)
|
||||
|
||||
t.Logf("%#v", string(by))
|
||||
}
|
||||
|
||||
func TestUnmarshal(t *testing.T) {
|
||||
assert := tinyssert.New(tinyssert.WithTest(t), tinyssert.WithPanic())
|
||||
|
||||
s := []byte(`
|
||||
<html>
|
||||
<body data-ipub-element="body">
|
||||
<section data-ipub-element="content">
|
||||
<img data-ipub-element="image" src="https://hello.com/world.png"/>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
var data ast.Section
|
||||
|
||||
err := xml.Unmarshal(s, &data)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Error(err.Error())
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
body := data.Body
|
||||
assert.Equal(ast.KindBody, body.Kind())
|
||||
|
||||
t.Logf("%#v", body)
|
||||
|
||||
content := body.FirstChild()
|
||||
assert.Equal(ast.KindContent, content.Kind())
|
||||
|
||||
t.Logf("%#v", content)
|
||||
|
||||
img := content.FirstChild().(*ast.Image)
|
||||
assert.Equal(ast.KindImage, img.Kind())
|
||||
assert.Equal("https://hello.com/world.png", img.Source())
|
||||
|
||||
t.Logf("%#v", img)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package ast
|
||||
|
||||
type Content struct {
|
||||
BaseNode
|
||||
}
|
||||
|
||||
var KindContent = NewNodeKind("content", &Content{})
|
||||
|
||||
func (e Content) Kind() NodeKind {
|
||||
return KindContent
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
src string
|
||||
|
||||
BaseNode
|
||||
}
|
||||
|
||||
var KindImage = NewNodeKind("image", &Image{})
|
||||
|
||||
func (e *Image) Kind() NodeKind {
|
||||
return KindImage
|
||||
}
|
||||
|
||||
func (e Image) Source() string {
|
||||
return e.src
|
||||
}
|
||||
|
||||
func (e *Image) SetSource(src string) {
|
||||
e.src = src
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package ast
|
||||
|
||||
type Package struct {
|
||||
BaseNode
|
||||
}
|
||||
|
||||
var KindPackage = NewNodeKind("package", &Package{})
|
||||
|
||||
func (e Package) Kind() NodeKind {
|
||||
return KindPackage
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package ast
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
)
|
||||
|
||||
type Section struct {
|
||||
XMLName xml.Name `xml:"html"`
|
||||
Body *Body `xml:"body"`
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
BaseNode
|
||||
}
|
||||
|
||||
var KindBody = NewNodeKind("body", &Body{})
|
||||
|
||||
func (e Body) Kind() NodeKind {
|
||||
return KindBody
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package attr
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Attribute interface {
|
||||
xml.MarshalerAttr
|
||||
xml.UnmarshalerAttr
|
||||
fmt.Stringer
|
||||
}
|
||||
|
||||
type BaseAttribute string
|
||||
|
||||
func (a BaseAttribute) MarshalXMLAttr(n xml.Name) (xml.Attr, error) {
|
||||
return xml.Attr{Name: n, Value: a.String()}, nil
|
||||
}
|
||||
|
||||
func (a *BaseAttribute) UnmarshalXMLAttr(attr xml.Attr) error {
|
||||
*a = BaseAttribute(attr.Value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a BaseAttribute) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package attr
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ErrInvalidName struct {
|
||||
Actual xml.Name
|
||||
Expected xml.Name
|
||||
}
|
||||
|
||||
var _ error = ErrInvalidName{}
|
||||
|
||||
func (err ErrInvalidName) Error() string {
|
||||
return fmt.Sprintf("attribute %q has invalid name, expected %q", FmtXMLName(err.Actual), FmtXMLName(err.Expected))
|
||||
}
|
||||
|
||||
type ErrInvalidValue struct {
|
||||
Attr xml.Attr
|
||||
Message string
|
||||
}
|
||||
|
||||
var _ error = ErrInvalidValue{}
|
||||
|
||||
func (err ErrInvalidValue) Error() string {
|
||||
return fmt.Sprintf("attribute %q's value %q is invalid: %s", FmtXMLName(err.Attr.Name), err.Attr.Value, err.Message)
|
||||
}
|
||||
|
||||
func FmtXMLName(n xml.Name) string {
|
||||
s := n.Local
|
||||
if n.Space != "" {
|
||||
s = fmt.Sprintf("%s:%s", n.Space, n.Local)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package attr
|
||||
|
||||
type DataElement = BaseAttribute
|
||||
@@ -1,112 +0,0 @@
|
||||
package element
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/ipub/element/attr"
|
||||
)
|
||||
|
||||
type Element interface {
|
||||
Kind() ElementKind
|
||||
}
|
||||
|
||||
type ElementChildren []Element
|
||||
|
||||
func (ec *ElementChildren) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
elErr := fmt.Errorf("unable to unsmarshal element %q", attr.FmtXMLName(start.Name))
|
||||
|
||||
i := slices.IndexFunc(start.Attr, func(a xml.Attr) bool {
|
||||
return a.Name == elementKindAttrName
|
||||
})
|
||||
if i == -1 {
|
||||
return errors.Join(elErr, fmt.Errorf("element kind not specified"))
|
||||
}
|
||||
|
||||
var k ElementKind
|
||||
if err := k.UnmarshalXMLAttr(start.Attr[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ks := elementKindList[k]
|
||||
|
||||
// Get a pointer of a new instance of the underlying implementation so we can
|
||||
// change it without manipulating the value inside the elementKindList.
|
||||
ep := reflect.New(reflect.TypeOf(ks))
|
||||
if ep.Elem().Kind() == reflect.Pointer {
|
||||
// If the implementation is a pointer, we need the underlying value so we can
|
||||
// manipulate it.
|
||||
ep = reflect.New(reflect.TypeOf(ks).Elem())
|
||||
}
|
||||
|
||||
if err := d.DecodeElement(ep.Interface(), &start); err != nil && err != io.EOF {
|
||||
return errors.Join(elErr, err)
|
||||
}
|
||||
|
||||
if ec == nil {
|
||||
c := ElementChildren{}
|
||||
ec = &c
|
||||
}
|
||||
|
||||
s := *ec
|
||||
s = append(s, ep.Interface().(Element))
|
||||
*ec = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ElementKind string
|
||||
|
||||
// NewElementKind registers a new Element implementation to a private list which is
|
||||
// consumed bu [ElementChildren] to properly find what underlying type is a children
|
||||
// of another element struct.
|
||||
func NewElementKind(n string, s Element) ElementKind {
|
||||
k := ElementKind(n)
|
||||
|
||||
if _, ok := elementKindList[k]; ok {
|
||||
panic(fmt.Sprintf("element kind %q already registered", n))
|
||||
}
|
||||
|
||||
elementKindList[k] = s
|
||||
return k
|
||||
}
|
||||
|
||||
func (k ElementKind) MarshalXMLAttr(n xml.Name) (xml.Attr, error) {
|
||||
if n != elementKindAttrName {
|
||||
return xml.Attr{}, attr.ErrInvalidName{Actual: n, Expected: elementKindAttrName}
|
||||
}
|
||||
return xml.Attr{Name: elementKindAttrName, Value: k.String()}, nil
|
||||
}
|
||||
|
||||
func (k *ElementKind) UnmarshalXMLAttr(a xml.Attr) error {
|
||||
ak := ElementKind(a.Value)
|
||||
|
||||
if _, ok := elementKindList[ak]; !ok {
|
||||
v := make([]string, 0, len(elementKindList))
|
||||
for k := range elementKindList {
|
||||
v = append(v, k.String())
|
||||
}
|
||||
return attr.ErrInvalidValue{
|
||||
Attr: a,
|
||||
Message: fmt.Sprintf("must be a registered element (%q)", strings.Join(v, `", "`)),
|
||||
}
|
||||
}
|
||||
|
||||
*k = ak
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k ElementKind) String() string {
|
||||
return string(k)
|
||||
}
|
||||
|
||||
var (
|
||||
elementKindList = make(map[ElementKind]Element)
|
||||
elementKindAttrName = xml.Name{Local: "data-ipub-element"}
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
package element_test
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/ipub/element"
|
||||
)
|
||||
|
||||
func Test(t *testing.T) {
|
||||
d := element.Section{
|
||||
Body: element.Body{
|
||||
Test: "helloworld",
|
||||
Children: []element.Element{
|
||||
&element.Paragraph{
|
||||
DataElement: element.ParagraphKind,
|
||||
Text: "hello world",
|
||||
Test: "testvalue",
|
||||
},
|
||||
&element.Paragraph{
|
||||
DataElement: element.ParagraphKind,
|
||||
|
||||
Text: "hello world 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b, err := xml.Marshal(d)
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
t.Logf("%#v", string(b))
|
||||
|
||||
var ud element.Section
|
||||
err = xml.Unmarshal(b, &ud)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
t.Logf("%#v", ud)
|
||||
t.Logf("%#v", ud.Body.Children[0])
|
||||
t.Logf("%#v", ud.Body.Children[1])
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package element
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
type Section struct {
|
||||
XMLName xml.Name `xml:"html"`
|
||||
Body Body `xml:"body"`
|
||||
}
|
||||
|
||||
var KindSection = NewElementKind("section", Section{})
|
||||
|
||||
func (Section) Kind() ElementKind {
|
||||
return KindSection
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
XMLName xml.Name `xml:"body"`
|
||||
Test string `xml:"test,attr"`
|
||||
|
||||
Children ElementChildren `xml:",any"`
|
||||
}
|
||||
|
||||
var KindBody = NewElementKind("body", Body{})
|
||||
|
||||
func (Body) Kind() ElementKind {
|
||||
return KindBody
|
||||
}
|
||||
|
||||
type Paragraph struct {
|
||||
XMLName xml.Name `xml:"p"`
|
||||
DataElement ElementKind `xml:"data-ipub-element,attr"`
|
||||
Test string `xml:"test,attr"`
|
||||
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
var KindParagraph = NewElementKind("paragraph", Paragraph{})
|
||||
|
||||
func (Paragraph) Kind() ElementKind {
|
||||
return KindParagraph
|
||||
}
|
||||
89
makefile
89
makefile
@@ -1,89 +0,0 @@
|
||||
PORT?=8080
|
||||
|
||||
lint:
|
||||
golangci-lint run .
|
||||
|
||||
fmt:
|
||||
go fmt .
|
||||
golangci-lint run --fix .
|
||||
|
||||
dev/server:
|
||||
go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 \
|
||||
go run github.com/air-verse/air@v1.52.2 \
|
||||
--build.cmd "go build -o tmp/bin/main ./cmd" \
|
||||
--build.bin "tmp/bin/main" \
|
||||
--build.exclude_dir "node_modules" \
|
||||
--build.include_ext "go" \
|
||||
--build.stop_on_error "false" \
|
||||
--misc.clean_on_exit true \
|
||||
-- -dev -port $(PORT) -hostname 0.0.0.0
|
||||
|
||||
dev/assets:
|
||||
tailwindcss \
|
||||
-i ./assets/stylesheets/tailwind.css \
|
||||
-o ./assets/stylesheets/out.css \
|
||||
--watch
|
||||
|
||||
dev:
|
||||
$(MAKE) -j2 dev/assets dev/server
|
||||
|
||||
dev/debug:
|
||||
$(MAKE) -j2 debug dev/assets
|
||||
|
||||
editor/dev/server:
|
||||
cd ./editor; go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 \
|
||||
go run github.com/air-verse/air@v1.52.2 \
|
||||
--build.cmd "go build -o tmp/bin/main ./cmd" \
|
||||
--build.bin "tmp/bin/main" \
|
||||
--build.exclude_dir "node_modules" \
|
||||
--build.include_ext "go" \
|
||||
--build.stop_on_error "false" \
|
||||
--misc.clean_on_exit true \
|
||||
-- -dev -port $(PORT) -hostname 0.0.0.0
|
||||
|
||||
editor/dev/assets:
|
||||
cd ./editor; tailwindcss \
|
||||
-i ./assets/css/tailwind.css \
|
||||
-o ./assets/css/style.css \
|
||||
--watch
|
||||
|
||||
editor/dev:
|
||||
$(MAKE) -j2 editor/dev/assets editor/dev/server
|
||||
|
||||
debug:
|
||||
dlv debug -l 127.0.0.1:38697 \
|
||||
--continue \
|
||||
--accept-multiclient \
|
||||
--headless \
|
||||
./cmd -- -dev -port $(PORT) -hostname 0.0.0.0
|
||||
|
||||
build/assets:
|
||||
tailwindcss \
|
||||
-i ./assets/stylesheets/tailwind.css \
|
||||
-o ./assets/stylesheets/out.css \
|
||||
--minify
|
||||
|
||||
build: build/assets
|
||||
go build -o ./.dist/app .
|
||||
|
||||
run: build
|
||||
./.dist/app
|
||||
|
||||
epub/example:
|
||||
cd ./.epub/example; zip ./example.epub ./META-INF/container.xml ./OEBPS/* ./OEBPS/**/* ./mimetype
|
||||
|
||||
epub/example/server:
|
||||
cd ./.epub/example; http-server
|
||||
|
||||
calibre:
|
||||
mkdir -p ./tmp/calibre-library
|
||||
calibre \
|
||||
--no-update-check \
|
||||
--with-library=./tmp/calibre-library \
|
||||
./.epub/example/example.epub
|
||||
|
||||
clean:
|
||||
# Remove generated directories
|
||||
if [[ -d ".dist" ]]; then rm -r ./.dist; fi
|
||||
if [[ -d "tmp" ]]; then rm -r ./tmp; fi
|
||||
if [[ -d "bin" ]]; then rm -r ./bin; fi
|
||||
@@ -1,66 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Model interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
type ErrInvalidModel struct {
|
||||
Name string
|
||||
Errors []error
|
||||
}
|
||||
|
||||
var _ error = ErrInvalidModel{}
|
||||
|
||||
func (err ErrInvalidModel) Error() string {
|
||||
return fmt.Sprintf("model %q is invalid", err.Name)
|
||||
}
|
||||
|
||||
type ErrInvalidValue struct {
|
||||
Name string
|
||||
Actual any
|
||||
Expected []any
|
||||
}
|
||||
|
||||
var _ error = ErrInvalidValue{}
|
||||
|
||||
func (err ErrInvalidValue) Error() string {
|
||||
var msg string
|
||||
|
||||
if err.Name != "" {
|
||||
msg = fmt.Sprintf("%q has ", err.Name)
|
||||
}
|
||||
|
||||
msg = msg + "incorrect value"
|
||||
|
||||
if err.Actual != nil {
|
||||
msg = msg + fmt.Sprintf(" %q", err.Actual)
|
||||
}
|
||||
|
||||
if len(err.Expected) == 0 || err.Expected == nil {
|
||||
return msg
|
||||
}
|
||||
|
||||
msg = fmt.Sprintf("%s, expected %q", msg, err.Expected[0])
|
||||
if len(err.Expected) > 1 {
|
||||
if len(err.Expected) == 2 {
|
||||
msg = msg + fmt.Sprintf(" or %q", err.Expected[1])
|
||||
} else {
|
||||
for v := range err.Expected[1 : len(err.Expected)-1] {
|
||||
msg = msg + fmt.Sprintf(", %q", v)
|
||||
}
|
||||
msg = msg + fmt.Sprintf(", or %q", err.Expected[len(err.Expected)-1])
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
type ErrZeroValue ErrInvalidValue
|
||||
|
||||
func (err ErrZeroValue) Error() string {
|
||||
return fmt.Sprintf("%q has incorrect value, expected non-zero/non-empty value", err.Name)
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Permissions int64
|
||||
|
||||
var (
|
||||
_ sql.Scanner = (*Permissions)(nil)
|
||||
_ driver.Value = Permissions(0)
|
||||
_ fmt.Stringer = Permissions(0)
|
||||
)
|
||||
|
||||
func (p Permissions) Has(perm ...Permissions) bool {
|
||||
// Bitwise AND to compare if p has a permission
|
||||
//
|
||||
// If for example, p is 0x0010 ("edit.accessibility") and perm is
|
||||
// 0x0001 ("read"): 0x0010 AND 0x0001 = 0x0000, which is not equal
|
||||
// to 0x0001, return false.
|
||||
//
|
||||
// If p is 0x0011 ("edit.accessibility" and "read") and perm is
|
||||
// 0x0001 ("read"): 0x0011 AND 0x0001 results in 0x0001, which
|
||||
// is equal to 0x0001 ("read").
|
||||
if len(perm) == 0 {
|
||||
return false
|
||||
}
|
||||
if len(perm) == 1 {
|
||||
return p&perm[0] == perm[0]
|
||||
}
|
||||
for _, pe := range perm {
|
||||
if p&pe != pe {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Permissions) Add(perm ...Permissions) {
|
||||
if p == nil {
|
||||
t := Permissions(0)
|
||||
p = &t
|
||||
}
|
||||
// Bitwise OR to add permissions.
|
||||
//
|
||||
// If p is 0x0001 ("read") and pe is 0x0010 ("edit.accessibility"):
|
||||
// 0x0001 OR 0x0010 results in 0x0011, which means we added the "edit.accessibility" bit.
|
||||
for _, pe := range perm {
|
||||
*p = *p | pe
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Permissions) Remove(perm ...Permissions) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
// Bitwise NOT AND
|
||||
//
|
||||
// If p is 0x0011 ("read" + "edit.accessibility"), and perm is 0x0010 ("edit.accessibility"):
|
||||
// we first convert perm to a bit-mask using NOT, so it becomes 0x1101; then we use AND to
|
||||
// remove the "edit.accessibility", since 0x0011 AND 0x1101 results in 0x0001 ("read").
|
||||
for _, pe := range perm {
|
||||
*p = *p & (^pe)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Permissions) Scan(src any) error {
|
||||
switch src := src.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case int64:
|
||||
*p = Permissions(src)
|
||||
case string:
|
||||
if strings.HasPrefix(src, "0x") {
|
||||
i, err := strconv.ParseInt(strings.TrimPrefix(src, "0x"), 2, 64)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("Scan: unable to scan binary Permissions"), err)
|
||||
}
|
||||
return p.Scan(i)
|
||||
}
|
||||
i, err := strconv.ParseInt(src, 10, 64)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("Scan: unable to scan base10 Permissions"), err)
|
||||
}
|
||||
return p.Scan(i)
|
||||
case []byte:
|
||||
return p.Scan(string(src))
|
||||
default:
|
||||
return fmt.Errorf("Scan: unable to scan type %T into Permissions", src)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Permissions) Value() (driver.Value, error) {
|
||||
return int64(p), nil
|
||||
}
|
||||
|
||||
func (p Permissions) String() string {
|
||||
if p.Has(PermissionAuthor) {
|
||||
return "author"
|
||||
}
|
||||
|
||||
labels := []string{}
|
||||
for perm, l := range PermissionLabels {
|
||||
if p.Has(perm) {
|
||||
labels = append(labels, l)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(labels, ",")
|
||||
}
|
||||
|
||||
const (
|
||||
PermissionAuthor Permissions = 0x1111111111111111 // "author"
|
||||
PermissionAdminDelete Permissions = 0x1000000000000000 // "admin.delete" -----
|
||||
PermissionAdminAll Permissions = 0x0111110000000001 // "admin.all"
|
||||
PermissionAdminPublication Permissions = 0x0100000000000000 // "admin.publication"
|
||||
PermissionAdminMembers Permissions = 0x0010000000000000 // "admin.members"
|
||||
PermissionEditAll Permissions = 0x0000001111111111 // "edit.all" ---------
|
||||
PermissionEditPages Permissions = 0x0000000100000000 // "edit.pages"
|
||||
PermissionEditInteractions Permissions = 0x0000000010000000 // "edit.interactions"
|
||||
PermissionEditDialogs Permissions = 0x0000000000001000 // "edit.dialogs"
|
||||
PermissionEditTranslations Permissions = 0x0000000000000100 // "edit.translations"
|
||||
PermissionEditAccessibility Permissions = 0x0000000000000010 // "edit.accessibility"
|
||||
PermissionRead Permissions = 0x0000000000000001 // "read"
|
||||
)
|
||||
|
||||
var PermissionLabels = map[Permissions]string{
|
||||
PermissionAuthor: "author",
|
||||
PermissionAdminDelete: "admin.delete",
|
||||
PermissionAdminPublication: "admin.publication",
|
||||
PermissionAdminMembers: "admin.members",
|
||||
PermissionEditPages: "edit.pages",
|
||||
PermissionEditInteractions: "edit.interactions",
|
||||
PermissionEditDialogs: "edit.dialogs",
|
||||
PermissionEditTranslations: "edit.translations",
|
||||
PermissionEditAccessibility: "edit.accessibility",
|
||||
PermissionRead: "read",
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Publication struct {
|
||||
ID uuid.UUID // Must be unique, represented as base64 string in URLs
|
||||
Title string // Must not be empty
|
||||
DateCreated time.Time
|
||||
DateUpdated time.Time
|
||||
}
|
||||
|
||||
var _ Model = (*Publication)(nil)
|
||||
|
||||
func (p Publication) Validate() error {
|
||||
errs := []error{}
|
||||
if len(p.ID) == 0 {
|
||||
errs = append(errs, ErrZeroValue{Name: "UUID"})
|
||||
}
|
||||
if p.Title == "" {
|
||||
errs = append(errs, ErrZeroValue{Name: "Title"})
|
||||
}
|
||||
if p.DateCreated.IsZero() {
|
||||
errs = append(errs, ErrZeroValue{Name: "DateCreated"})
|
||||
}
|
||||
if p.DateUpdated.IsZero() {
|
||||
errs = append(errs, ErrZeroValue{Name: "DateUpdated"})
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return ErrInvalidModel{Name: "Publication", Errors: errs}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
ID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
DateCreated time.Time
|
||||
DateExpires time.Time
|
||||
}
|
||||
|
||||
func (t Token) Validate() error {
|
||||
errs := []error{}
|
||||
if len(t.ID) == 0 {
|
||||
errs = append(errs, ErrZeroValue{Name: "ID"})
|
||||
}
|
||||
if len(t.UserID) == 0 {
|
||||
errs = append(errs, ErrZeroValue{Name: "User"})
|
||||
}
|
||||
if t.DateCreated.IsZero() {
|
||||
errs = append(errs, ErrZeroValue{Name: "DateCreated"})
|
||||
}
|
||||
if t.DateExpires.IsZero() {
|
||||
errs = append(errs, ErrZeroValue{Name: "DateExpires"})
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return ErrInvalidModel{Name: "Token", Errors: errs}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"` // Must be unique
|
||||
Password []byte `json:"password"`
|
||||
|
||||
DateCreated time.Time `json:"date_created"`
|
||||
DateUpdated time.Time `json:"date_updated"`
|
||||
}
|
||||
|
||||
func (u User) Validate() error {
|
||||
errs := []error{}
|
||||
if len(u.ID) == 0 {
|
||||
errs = append(errs, ErrZeroValue{Name: "ID"})
|
||||
}
|
||||
if u.Username == "" {
|
||||
errs = append(errs, ErrZeroValue{Name: "Username"})
|
||||
}
|
||||
if len(u.Password) == 0 {
|
||||
errs = append(errs, ErrZeroValue{Name: "Password"})
|
||||
}
|
||||
if u.DateCreated.IsZero() {
|
||||
errs = append(errs, ErrZeroValue{Name: "DateCreated"})
|
||||
}
|
||||
if u.DateUpdated.IsZero() {
|
||||
errs = append(errs, ErrZeroValue{Name: "DateUpdated"})
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return ErrInvalidModel{Name: "User", Errors: errs}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "comicverse",
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/node": "^22.6.0",
|
||||
"blob-util": "^2.0.2",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"minio": "^8.0.1",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.0.3",
|
||||
"xml-js": "^1.6.11"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/model"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Permissions struct {
|
||||
baseRepostiory
|
||||
}
|
||||
|
||||
// Must be initiated after [User] and [Publication]
|
||||
func NewPermissions(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
log *slog.Logger,
|
||||
assert tinyssert.Assertions,
|
||||
) (*Permissions, error) {
|
||||
b := newBaseRepostiory(ctx, db, log, assert)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS publication_permissions (
|
||||
publication_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
permissions_value INTEGER NOT NULL DEFAULT '0',
|
||||
_permissions_text TEXT NOT NULL DEFAULT '', -- For display purposes only, may not always be up-to-date
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY(publication_id, user_id)
|
||||
FOREIGN KEY(publication_id)
|
||||
REFERENCES publications (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE RESTRICT,
|
||||
FOREIGN KEY(user_id)
|
||||
REFERENCES users (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE RESTRICT
|
||||
)
|
||||
`)
|
||||
|
||||
_, err = tx.ExecContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.Join(errors.New("unable to create publication tables"), err)
|
||||
}
|
||||
|
||||
return &Permissions{baseRepostiory: b}, nil
|
||||
}
|
||||
|
||||
func (repo Permissions) Create(publication, user uuid.UUID, permissions model.Permissions) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO publication_permissions (publication_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
|
||||
VALUES (:publication_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
|
||||
log := repo.log.With(slog.String("publication_id", publication.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("permissions", fmt.Sprintf("%d", permissions)),
|
||||
slog.String("permissions_text", permissions.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Inserting new publication permissions")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("publication_id", publication),
|
||||
sql.Named("user_id", user),
|
||||
sql.Named("permissions_value", permissions),
|
||||
sql.Named("permissions_text", permissions.String()),
|
||||
sql.Named("created_at", now.Format(dateFormat)),
|
||||
sql.Named("updated_at", now.Format(dateFormat)),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to insert publication permissions", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Permissions) GetByID(publication uuid.UUID, user uuid.UUID) (model.Permissions, error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
q := `
|
||||
SELECT permissions_value FROM publication_permissions
|
||||
WHERE publication_id = :publication_id
|
||||
AND user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("projcet_id", publication.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Getting by ID")
|
||||
|
||||
row := repo.db.QueryRowContext(repo.ctx, q,
|
||||
sql.Named("publication_id", user),
|
||||
sql.Named("user_id", user))
|
||||
|
||||
var p model.Permissions
|
||||
if err := row.Scan(&p); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to get permissions by ID", slog.String("error", err.Error()))
|
||||
return model.Permissions(0), errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetByUserID returns a publication_id-to-permissions map containing all publications and permissions that said userID
|
||||
// has relation to.
|
||||
func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]model.Permissions, err error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
// Begin tx so we don't read rows as they are being updated
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT publication_id, permissions_value FROM publication_permissions
|
||||
WHERE user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("user_id", user.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Getting by user ID")
|
||||
|
||||
rows, err := tx.QueryContext(repo.ctx, q, sql.Named("user_id", user))
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to get permissions by user ID", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = rows.Close()
|
||||
if err != nil {
|
||||
err = errors.Join(ErrCloseConn, err)
|
||||
}
|
||||
}()
|
||||
|
||||
ps := map[uuid.UUID]model.Permissions{}
|
||||
|
||||
for rows.Next() {
|
||||
var publication uuid.UUID
|
||||
var permissions model.Permissions
|
||||
|
||||
err := rows.Scan(&publication, &permissions)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan permissions of user id", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
ps[publication] = permissions
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func (repo Permissions) Update(publication, user uuid.UUID, permissions model.Permissions) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
UPDATE publication_permissions
|
||||
SET permissions_value = :permissions_value
|
||||
_permissions_text = :permissions_text
|
||||
updated_at = :updated_at
|
||||
WHERE publication_uuid = :publication_uuid
|
||||
AND user_uuid = :user_uuid
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("publication_id", publication.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("permissions", fmt.Sprintf("%d", permissions)),
|
||||
slog.String("permissions_text", permissions.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Updating publication permissions")
|
||||
|
||||
now := time.Now()
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("permissions_value", permissions),
|
||||
sql.Named("permissions_text", permissions.String()),
|
||||
sql.Named("updated_at", now.Format(dateFormat)),
|
||||
sql.Named("publication_id", publication),
|
||||
sql.Named("user_id", user),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to update publication permissions", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Permissions) Delete(publication, user uuid.UUID) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := `
|
||||
DELETE FROM publication_permissions
|
||||
WHERE publication_id = :publication_id
|
||||
AND user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("publication_id", publication.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Deleting publication permissions")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("publication_id", publication),
|
||||
sql.Named("user_id", user),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to delete publication permissions", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/model"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Publication struct {
|
||||
baseRepostiory
|
||||
}
|
||||
|
||||
func NewPublication(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Publication, error) {
|
||||
b := newBaseRepostiory(ctx, db, log, assert)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS publications (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.Join(errors.New("unable to create publication tables"), err)
|
||||
}
|
||||
|
||||
return &Publication{baseRepostiory: b}, nil
|
||||
}
|
||||
|
||||
func (repo Publication) Create(p model.Publication) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
if err := p.Validate(); err != nil {
|
||||
return errors.Join(ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO publications (id, title, created_at, updated_at)
|
||||
VALUES (:id, :title, :created_at, :updated_at)
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Inserting new publication")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("id", p.ID),
|
||||
sql.Named("title", p.Title),
|
||||
sql.Named("created_at", p.DateCreated.Format(dateFormat)),
|
||||
sql.Named("updated_at", p.DateUpdated.Format(dateFormat)),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to insert publication", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Publication) GetByID(publicationID uuid.UUID) (publication model.Publication, err error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
q := `
|
||||
SELECT id, title, created_at, updated_at FROM publications
|
||||
WHERE id = :id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("query", q), slog.String("id", publicationID.String()))
|
||||
log.DebugContext(repo.ctx, "Getting publication by ID")
|
||||
|
||||
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("id", publicationID))
|
||||
|
||||
var id uuid.UUID
|
||||
var title string
|
||||
var dateCreatedStr, dateUpdatedStr string
|
||||
|
||||
err = row.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
|
||||
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
|
||||
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
|
||||
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
return model.Publication{
|
||||
ID: id,
|
||||
Title: title,
|
||||
DateCreated: dateCreated,
|
||||
DateUpdated: dateUpdated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (repo Publication) GetByIDs(ids []uuid.UUID) (publications []model.Publication, err error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
// Begin tx so we don't read rows as they are being updated
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
c := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
c[i] = fmt.Sprintf("id = '%s'", id.String())
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT id, title, created_at, updated_at FROM publications
|
||||
WHERE %s
|
||||
`, strings.Join(c, " OR "))
|
||||
|
||||
log := repo.log.With(slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Getting publications by IDs")
|
||||
|
||||
rows, err := tx.QueryContext(repo.ctx, q)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to get publications by IDs", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = rows.Close()
|
||||
if err != nil {
|
||||
err = errors.Join(ErrCloseConn, err)
|
||||
}
|
||||
}()
|
||||
|
||||
ps := []model.Publication{}
|
||||
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var title string
|
||||
var dateCreatedStr, dateUpdatedStr string
|
||||
|
||||
err := rows.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
ps = append(ps, model.Publication{
|
||||
ID: id,
|
||||
Title: title,
|
||||
DateCreated: dateCreated,
|
||||
DateUpdated: dateUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func (repo Publication) Update(p model.Publication) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
if err := p.Validate(); err != nil {
|
||||
return errors.Join(ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
UPDATE publications
|
||||
SET title = :title
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Updating publication")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("title", p.Title),
|
||||
sql.Named("updated_at", p.DateUpdated.Format(dateFormat)),
|
||||
sql.Named("id", p.ID),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to insert publication", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Publication) DeleteByID(id uuid.UUID) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := `
|
||||
DELETE FROM publications WHERE id = :id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", id.String()), slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Deleting publication")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q, sql.Named("id", id))
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to delete publication", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
)
|
||||
|
||||
// TODO: Add rowback to all return errors, or use context to cancel operations
|
||||
|
||||
type baseRepostiory struct {
|
||||
db *sql.DB
|
||||
|
||||
ctx context.Context
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func newBaseRepostiory(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) baseRepostiory {
|
||||
assert.NotNil(db)
|
||||
assert.NotNil(ctx)
|
||||
assert.NotNil(log)
|
||||
|
||||
return baseRepostiory{
|
||||
db: db,
|
||||
ctx: ctx,
|
||||
log: log,
|
||||
assert: assert,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// TODO: Change all ErrDatabaseConn to ErrCloseConn
|
||||
// TODO: Change error to be agnostic to underlying storage type
|
||||
ErrDatabaseConn = errors.New("repository: failed to begin transaction/connection with database")
|
||||
ErrCloseConn = errors.New("repository: failed to close/commit connection")
|
||||
ErrExecuteQuery = errors.New("repository: failed to execute query")
|
||||
ErrCommitQuery = errors.New("repository: failed to commit transaction")
|
||||
ErrInvalidInput = errors.New("repository: data sent to save is invalid")
|
||||
ErrInvalidOutput = errors.New("repository: data found is not valid")
|
||||
ErrNotFound = sql.ErrNoRows
|
||||
)
|
||||
|
||||
var dateFormat = time.RFC3339
|
||||
|
||||
type scan interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
)
|
||||
|
||||
type RoleRepository struct {
|
||||
db *sql.DB
|
||||
|
||||
ctx context.Context
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/model"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
baseRepostiory
|
||||
}
|
||||
|
||||
// Must be initiated after [User]
|
||||
func NewToken(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Token, error) {
|
||||
b := newBaseRepostiory(ctx, db, log, assert)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY(id, user_id),
|
||||
FOREIGN KEY(user_id)
|
||||
REFERENCES users (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE RESTRICT
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.Join(errors.New("unable to create project tables"), err)
|
||||
}
|
||||
|
||||
return &Token{baseRepostiory: b}, nil
|
||||
}
|
||||
|
||||
func (repo Token) Create(token model.Token) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
if err := token.Validate(); err != nil {
|
||||
return errors.Join(ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO tokens (id, user_id, created_at, expires_at)
|
||||
VALUES (:id, :user_id, :created_at, :expires_at)
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", token.ID.String()),
|
||||
slog.String("user_id", token.UserID.String()),
|
||||
slog.String("expires", token.DateExpires.Format(dateFormat)),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Inserting new user token")
|
||||
|
||||
// TODO: Check rows affected
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("id", token.ID),
|
||||
sql.Named("user_id", token.UserID),
|
||||
sql.Named("created_at", token.DateCreated.Format(dateFormat)),
|
||||
sql.Named("expired_at", token.DateExpires.Format(dateFormat)),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to insert token", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Token) Get(tokenID, userID uuid.UUID) (model.Token, error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
q := `
|
||||
SELECT (id, user_id, created_at, expired_at) FROM tokens
|
||||
WHERE id = :id
|
||||
AND user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", tokenID.String()),
|
||||
slog.String("user_id", userID.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Getting token")
|
||||
|
||||
row := repo.db.QueryRowContext(repo.ctx, q,
|
||||
sql.Named("id", tokenID),
|
||||
sql.Named("user_id", userID),
|
||||
)
|
||||
|
||||
token, err := repo.scan(row)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan token", slog.String("error", err.Error()))
|
||||
return model.Token{}, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (repo Token) GetByUserID(userID uuid.UUID) (tokens []model.Token, err error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
q := `
|
||||
SELECT (id, user_id, created_at, expired_at) FROM tokens
|
||||
WHERE user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(
|
||||
slog.String("user_id", userID.String()),
|
||||
slog.String("query", q),
|
||||
)
|
||||
log.DebugContext(repo.ctx, "Getting users tokens")
|
||||
|
||||
rows, err := repo.db.QueryContext(repo.ctx, q,
|
||||
sql.Named("user_id", userID),
|
||||
)
|
||||
|
||||
defer func() {
|
||||
err = rows.Close()
|
||||
if err != nil {
|
||||
err = errors.Join(ErrCloseConn, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to get user tokens", slog.String("error", err.Error()))
|
||||
return []model.Token{}, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
tokens = []model.Token{}
|
||||
for rows.Next() {
|
||||
t, err := repo.scan(rows)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan token", slog.String("error", err.Error()))
|
||||
return []model.Token{}, err
|
||||
}
|
||||
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan token rows", slog.String("error", err.Error()))
|
||||
return []model.Token{}, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func (repo Token) scan(row scan) (model.Token, error) {
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
var token model.Token
|
||||
var createdStr, expiresStr string
|
||||
|
||||
err := row.Scan(&token.ID, &token.UserID, &createdStr, &expiresStr)
|
||||
if err != nil {
|
||||
return model.Token{}, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
dateCreated, err := time.Parse(dateFormat, createdStr)
|
||||
if err != nil {
|
||||
return model.Token{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateExpires, err := time.Parse(dateFormat, createdStr)
|
||||
if err != nil {
|
||||
return model.Token{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
token.DateCreated = dateCreated
|
||||
token.DateExpires = dateExpires
|
||||
|
||||
if err := token.Validate(); err != nil {
|
||||
return model.Token{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (repo Token) Delete(token, user uuid.UUID) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := `
|
||||
DELETE FROM tokens
|
||||
WHERE id = :id
|
||||
AND user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", token.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Deleting token")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("id", token),
|
||||
sql.Named("user_id", user),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to delete token", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/model"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
baseRepostiory
|
||||
}
|
||||
|
||||
func NewUser(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
assert tinyssert.Assertions,
|
||||
) (*User, error) {
|
||||
assert.NotNil(ctx)
|
||||
assert.NotNil(db)
|
||||
assert.NotNil(logger)
|
||||
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b := newBaseRepostiory(ctx, db, logger, assert)
|
||||
|
||||
return &User{
|
||||
baseRepostiory: b,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (repo *User) Create(u model.User) (model.User, error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.log)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
if err := u.Validate(); err != nil {
|
||||
return model.User{}, errors.Join(ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return model.User{}, errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO users (id, username, password_hash, created_at, updated_at)
|
||||
VALUES (:id, :username, :password_hash, :created_at, :updated_at)
|
||||
`
|
||||
|
||||
log := repo.log.With(
|
||||
slog.String("id", u.ID.String()),
|
||||
slog.String("username", u.Username),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Inserting new user")
|
||||
|
||||
t := time.Now()
|
||||
|
||||
passwd := base64.URLEncoding.EncodeToString(u.Password)
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("id", u.ID),
|
||||
sql.Named("username", u.Username),
|
||||
sql.Named("password_hash", passwd),
|
||||
sql.Named("created_at", t.Format(dateFormat)),
|
||||
sql.Named("updated_at", t.Format(dateFormat)))
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to create user", slog.String("error", err.Error()))
|
||||
return model.User{}, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return model.User{}, errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (repo *User) GetByID(id uuid.UUID) (model.User, error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.log)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
q := `
|
||||
SELECT id, username, password_hash, created_at, updated_at FROM users
|
||||
WHERE id = :id
|
||||
`
|
||||
|
||||
log := repo.log.With(
|
||||
slog.String("id", id.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Querying user")
|
||||
|
||||
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("username", id))
|
||||
|
||||
user, err := repo.scan(row)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to query user", slog.String("error", err.Error()))
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (repo *User) GetByUsername(username string) (model.User, error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.log)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
q := `
|
||||
SELECT id, username, password_hash, created_at, updated_at FROM users
|
||||
WHERE username = :username
|
||||
`
|
||||
|
||||
log := repo.log.With(
|
||||
slog.String("username", username),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Querying user")
|
||||
|
||||
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("username", username))
|
||||
|
||||
user, err := repo.scan(row)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to query user", slog.String("error", err.Error()))
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (repo *User) scan(row scan) (model.User, error) {
|
||||
var user model.User
|
||||
var password_hashStr, createdStr, updatedStr string
|
||||
err := row.Scan(&user.ID, &user.Username, &password_hashStr, &createdStr, &updatedStr)
|
||||
if err != nil {
|
||||
return model.User{}, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
passwd, err := base64.URLEncoding.DecodeString(password_hashStr)
|
||||
if err != nil {
|
||||
return model.User{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
created, err := time.Parse(dateFormat, createdStr)
|
||||
if err != nil {
|
||||
return model.User{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
updated, err := time.Parse(dateFormat, updatedStr)
|
||||
if err != nil {
|
||||
return model.User{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
user.Password = passwd
|
||||
user.DateCreated = created
|
||||
user.DateUpdated = updated
|
||||
|
||||
if err := user.Validate(); err != nil {
|
||||
return model.User{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (repo *User) DeleteByID(id uuid.UUID) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.log)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := `
|
||||
DELETE FROM users WHERE id = :id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", id.String()), slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Deleting user")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q, sql.Named("id", id))
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to delete user", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/service"
|
||||
"code.capytal.cc/capytal/comicverse/templates"
|
||||
"code.capytal.cc/loreddev/smalltrip/problem"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type publicationController struct {
|
||||
publicationSvc *service.Publication
|
||||
|
||||
templates templates.ITemplate
|
||||
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func newPublicationController(
|
||||
publicationService *service.Publication,
|
||||
templates templates.ITemplate,
|
||||
assertions tinyssert.Assertions,
|
||||
) *publicationController {
|
||||
return &publicationController{
|
||||
publicationSvc: publicationService,
|
||||
templates: templates,
|
||||
assert: assertions,
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl publicationController) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx := NewUserContext(r.Context())
|
||||
|
||||
userID, ok := userCtx.GetUserID()
|
||||
if !ok {
|
||||
userCtx.Unathorize(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
publications, err := ctrl.publicationSvc.ListOwnedBy(userID)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ps := make([]struct {
|
||||
ID string
|
||||
Title string
|
||||
}, len(publications))
|
||||
|
||||
for i, publication := range publications {
|
||||
ps[i] = struct {
|
||||
ID string
|
||||
Title string
|
||||
}{
|
||||
ID: base64.URLEncoding.EncodeToString([]byte(publication.ID.String())),
|
||||
Title: publication.Title,
|
||||
}
|
||||
}
|
||||
|
||||
err = ctrl.templates.ExecuteTemplate(w, "dashboard", ps)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl publicationController) getPublication(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Handle private publications
|
||||
|
||||
shortPublicationID := r.PathValue("publicationID")
|
||||
|
||||
id, err := base64.URLEncoding.DecodeString(shortPublicationID)
|
||||
if err != nil {
|
||||
problem.NewBadRequest(fmt.Sprintf("Incorrectly encoded publication ID: %s", err.Error())).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
publicationID, err := uuid.ParseBytes(id)
|
||||
if err != nil {
|
||||
problem.NewBadRequest("Publication ID is not a valid UUID").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
publication, err := ctrl.publicationSvc.Get(publicationID)
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
problem.NewNotFound().ServeHTTP(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Return publication template
|
||||
b, err := json.Marshal(publication)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if _, err := w.Write(b); err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl publicationController) createPublication(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx := NewUserContext(r.Context())
|
||||
|
||||
userID, ok := userCtx.GetUserID()
|
||||
if !ok {
|
||||
userCtx.Unathorize(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
title := r.FormValue("title")
|
||||
if title == "" {
|
||||
problem.NewBadRequest(`Missing "title" parameter`).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
publication, err := ctrl.publicationSvc.Create(title, userID)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/publication/%s/", base64.URLEncoding.EncodeToString([]byte(publication.ID.String())))
|
||||
http.Redirect(w, r, path, http.StatusSeeOther)
|
||||
}
|
||||
154
router/router.go
154
router/router.go
@@ -1,154 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/service"
|
||||
"code.capytal.cc/capytal/comicverse/templates"
|
||||
"code.capytal.cc/loreddev/smalltrip"
|
||||
"code.capytal.cc/loreddev/smalltrip/middleware"
|
||||
"code.capytal.cc/loreddev/smalltrip/multiplexer"
|
||||
"code.capytal.cc/loreddev/smalltrip/problem"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
)
|
||||
|
||||
type router struct {
|
||||
userService *service.User
|
||||
tokenService *service.Token
|
||||
publicationService *service.Publication
|
||||
|
||||
templates templates.ITemplate
|
||||
assets fs.FS
|
||||
cache bool
|
||||
|
||||
assert tinyssert.Assertions
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func New(cfg Config) (http.Handler, error) {
|
||||
if cfg.UserService == nil {
|
||||
return nil, errors.New("user service is nil")
|
||||
}
|
||||
if cfg.TokenService == nil {
|
||||
return nil, errors.New("token service is nil")
|
||||
}
|
||||
if cfg.PublicationService == nil {
|
||||
return nil, errors.New("publication service is nil")
|
||||
}
|
||||
if cfg.Templates == nil {
|
||||
return nil, errors.New("templates is nil")
|
||||
}
|
||||
if cfg.Assets == nil {
|
||||
return nil, errors.New("static files is nil")
|
||||
}
|
||||
if cfg.Assertions == nil {
|
||||
return nil, errors.New("assertions is nil")
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
return nil, errors.New("logger is nil")
|
||||
}
|
||||
|
||||
r := &router{
|
||||
userService: cfg.UserService,
|
||||
tokenService: cfg.TokenService,
|
||||
publicationService: cfg.PublicationService,
|
||||
|
||||
templates: cfg.Templates,
|
||||
assets: cfg.Assets,
|
||||
cache: !cfg.DisableCache,
|
||||
|
||||
assert: cfg.Assertions,
|
||||
log: cfg.Logger,
|
||||
}
|
||||
|
||||
return r.setup(), nil
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
UserService *service.User
|
||||
TokenService *service.Token
|
||||
PublicationService *service.Publication
|
||||
|
||||
Templates templates.ITemplate
|
||||
Assets fs.FS
|
||||
DisableCache bool
|
||||
|
||||
Assertions tinyssert.Assertions
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func (router *router) setup() http.Handler {
|
||||
router.assert.NotNil(router.log)
|
||||
router.assert.NotNil(router.assets)
|
||||
|
||||
log := router.log
|
||||
|
||||
log.Debug("Initializing router")
|
||||
|
||||
mux := multiplexer.New()
|
||||
mux = multiplexer.WithFormMethod(mux, "x-method")
|
||||
mux = multiplexer.WithPatternRules(mux,
|
||||
multiplexer.EnsureMethod(),
|
||||
multiplexer.EnsureStrictEnd(),
|
||||
multiplexer.EnsureTrailingSlash(),
|
||||
)
|
||||
|
||||
r := smalltrip.NewRouter(
|
||||
smalltrip.WithMultiplexer(mux),
|
||||
smalltrip.WithLogger(log.WithGroup("smalltrip")),
|
||||
)
|
||||
|
||||
r.Use(middleware.Logger(log.WithGroup("requests")))
|
||||
if router.cache {
|
||||
r.Use(middleware.Cache())
|
||||
} else {
|
||||
r.Use(middleware.DisableCache())
|
||||
}
|
||||
|
||||
r.Use(problem.PanicMiddleware())
|
||||
// TODO: when the HandlerDevpage is completed on the problem package, we
|
||||
// will provide it a custom template here:
|
||||
// r.Use(problem.Middleware())
|
||||
|
||||
userController := newUserController(userControllerCfg{
|
||||
UserService: router.userService,
|
||||
TokenService: router.tokenService,
|
||||
LoginPath: "/login/",
|
||||
RedirectPath: "/",
|
||||
Templates: router.templates,
|
||||
Assert: router.assert,
|
||||
})
|
||||
publicationController := newPublicationController(router.publicationService, router.templates, router.assert)
|
||||
|
||||
r.Handle("GET /assets/{_file...}", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
|
||||
|
||||
r.Use(userController.userMiddleware)
|
||||
|
||||
r.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add a way to the user to bypass this check and see the landing page.
|
||||
// Probably a query parameter to bypass like "?landing=true"
|
||||
if _, ok := NewUserContext(r.Context()).GetUserID(); ok {
|
||||
publicationController.dashboard(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err := router.templates.ExecuteTemplate(w, "landing", nil)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
r.HandleFunc("GET /login/{$}", userController.login)
|
||||
r.HandleFunc("POST /login/{$}", userController.login)
|
||||
r.HandleFunc("GET /register/{$}", userController.register)
|
||||
r.HandleFunc("POST /register/{$}", userController.register)
|
||||
|
||||
// TODO: Provide/redirect short publication-id paths to long paths with the publication title as URL /publications/title-of-the-publication-<start of uuid>
|
||||
r.HandleFunc("GET /publication/{publicationID}/{$}", publicationController.getPublication)
|
||||
r.HandleFunc("POST /publication/{$}", publicationController.createPublication)
|
||||
|
||||
return r
|
||||
}
|
||||
293
router/user.go
293
router/user.go
@@ -1,293 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/service"
|
||||
"code.capytal.cc/capytal/comicverse/templates"
|
||||
"code.capytal.cc/loreddev/smalltrip/middleware"
|
||||
"code.capytal.cc/loreddev/smalltrip/problem"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type userController struct {
|
||||
userSvc *service.User
|
||||
tokenSvc *service.Token
|
||||
|
||||
loginPath string
|
||||
redirectPath string
|
||||
templates templates.ITemplate
|
||||
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func newUserController(cfg userControllerCfg) userController {
|
||||
cfg.Assert.NotNil(cfg.UserService)
|
||||
cfg.Assert.NotNil(cfg.TokenService)
|
||||
cfg.Assert.NotZero(cfg.LoginPath)
|
||||
cfg.Assert.NotZero(cfg.RedirectPath)
|
||||
cfg.Assert.NotNil(cfg.Templates)
|
||||
|
||||
return userController{
|
||||
userSvc: cfg.UserService,
|
||||
tokenSvc: cfg.TokenService,
|
||||
loginPath: cfg.LoginPath,
|
||||
redirectPath: cfg.RedirectPath,
|
||||
templates: cfg.Templates,
|
||||
assert: cfg.Assert,
|
||||
}
|
||||
}
|
||||
|
||||
type userControllerCfg struct {
|
||||
UserService *service.User
|
||||
TokenService *service.Token
|
||||
|
||||
LoginPath string
|
||||
RedirectPath string
|
||||
Templates templates.ITemplate
|
||||
|
||||
Assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func (ctrl userController) login(w http.ResponseWriter, r *http.Request) {
|
||||
ctrl.assert.NotNil(ctrl.templates) // TODO?: Remove these types of assertions, since golang will panic anyway
|
||||
ctrl.assert.NotNil(ctrl.userSvc) // when the methods of these functions are called
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
err := ctrl.templates.ExecuteTemplate(w, "login", nil)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
problem.NewMethodNotAllowed([]string{http.MethodGet, http.MethodPost}).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username, passwd := r.FormValue("username"), r.FormValue("password")
|
||||
if username == "" {
|
||||
problem.NewBadRequest(`Missing "username" form value`).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if passwd == "" {
|
||||
problem.NewBadRequest(`Missing "password" form value`).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Move token issuing to it's own service, make UserService.Login just return the user
|
||||
user, err := ctrl.userSvc.Login(username, passwd)
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
problem.NewNotFound().ServeHTTP(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := ctrl.tokenSvc.Issue(user)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: harden the cookie policy to the same domain
|
||||
cookie := &http.Cookie{
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Name: "authorization",
|
||||
Value: token,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
http.Redirect(w, r, ctrl.redirectPath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (ctrl userController) register(w http.ResponseWriter, r *http.Request) {
|
||||
ctrl.assert.NotNil(ctrl.templates)
|
||||
ctrl.assert.NotNil(ctrl.userSvc)
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
err := ctrl.templates.ExecuteTemplate(w, "register", nil)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
problem.NewMethodNotAllowed([]string{http.MethodGet, http.MethodPost}).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username, passwd := r.FormValue("username"), r.FormValue("password")
|
||||
if username == "" {
|
||||
problem.NewBadRequest(`Missing "username" form value`).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if passwd == "" {
|
||||
problem.NewBadRequest(`Missing "password" form value`).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := ctrl.userSvc.Register(username, passwd)
|
||||
if errors.Is(err, service.ErrUsernameAlreadyExists) || errors.Is(err, service.ErrPasswordTooLong) {
|
||||
problem.NewBadRequest(err.Error()).ServeHTTP(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := ctrl.tokenSvc.Issue(user)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: harden the cookie policy to the same domain
|
||||
cookie := &http.Cookie{
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Name: "authorization",
|
||||
Value: token,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (ctrl userController) userMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var token string
|
||||
if t := r.Header.Get("Authorization"); t != "" {
|
||||
token = t
|
||||
} else if cs := r.CookiesNamed("authorization"); len(cs) > 0 {
|
||||
token = cs[0].Value // TODO: Validate cookie
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Create some way to show the user what error occurred with the token,
|
||||
// not just the Unathorize method of UserContext. Maybe a web socket to send
|
||||
// the message? Or maybe a custom Header? A header can be intercepted via a
|
||||
// listener in the HTMX framework probably.
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
t, err := ctrl.tokenSvc.Parse(token)
|
||||
if err != nil {
|
||||
ctx = context.WithValue(ctx, "x-comicverse-user-token-error", err)
|
||||
} else {
|
||||
ctx = context.WithValue(ctx, "x-comicverse-user-token", t)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
var _ middleware.Middleware = userController{}.userMiddleware
|
||||
|
||||
type UserContext struct {
|
||||
context.Context
|
||||
}
|
||||
|
||||
func NewUserContext(ctx context.Context) UserContext {
|
||||
if uctxp, ok := ctx.(*UserContext); ok && uctxp != nil {
|
||||
return *uctxp
|
||||
} else if uctx, ok := ctx.(UserContext); ok {
|
||||
return uctx
|
||||
}
|
||||
return UserContext{Context: ctx}
|
||||
}
|
||||
|
||||
func (ctx UserContext) Unathorize(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add a way to redirect to the login page in case of a incorrect token.
|
||||
// Since we use HTMX, we can't just return a redirect response probably,
|
||||
// the framework will just get the login page html and not redirect the user to the page.
|
||||
|
||||
var p problem.Problem
|
||||
if err, ok := ctx.GetTokenErr(); ok {
|
||||
p = problem.NewUnauthorized(problem.AuthSchemeBearer, problem.WithError(err))
|
||||
} else {
|
||||
p = problem.NewUnauthorized(problem.AuthSchemeBearer)
|
||||
}
|
||||
|
||||
p.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (ctx UserContext) GetUserID() (uuid.UUID, bool) {
|
||||
claims, ok := ctx.GetClaims()
|
||||
if !ok {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
|
||||
sub, ok := claims["sub"]
|
||||
if !ok {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
|
||||
s, ok := sub.(string)
|
||||
if !ok {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
// TODO?: Add error to error context
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (ctx UserContext) GetClaims() (jwt.MapClaims, bool) {
|
||||
token, ok := ctx.GetToken()
|
||||
if !ok {
|
||||
return jwt.MapClaims{}, false
|
||||
}
|
||||
|
||||
// TODO: Make claims type be registered in the user service
|
||||
// TODO: Structure claims type
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return jwt.MapClaims{}, false
|
||||
}
|
||||
|
||||
return claims, true
|
||||
}
|
||||
|
||||
func (ctx UserContext) GetToken() (*jwt.Token, bool) {
|
||||
t := ctx.Value("x-comicverse-user-token")
|
||||
if t == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
token, ok := t.(*jwt.Token)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return token, true
|
||||
}
|
||||
|
||||
func (ctx UserContext) GetTokenErr() (error, bool) {
|
||||
e := ctx.Value("x-comicverse-user-token-error")
|
||||
if e == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
err, ok := e.(error)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return err, true
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/model"
|
||||
"code.capytal.cc/capytal/comicverse/repository"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Publication struct {
|
||||
publicationRepo *repository.Publication
|
||||
permissionRepo *repository.Permissions
|
||||
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func NewPublication(
|
||||
publication *repository.Publication,
|
||||
permissions *repository.Permissions,
|
||||
logger *slog.Logger,
|
||||
assertions tinyssert.Assertions,
|
||||
) *Publication {
|
||||
return &Publication{
|
||||
publicationRepo: publication,
|
||||
permissionRepo: permissions,
|
||||
|
||||
log: logger,
|
||||
assert: assertions,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc Publication) Get(publicationID uuid.UUID) (model.Publication, error) {
|
||||
p, err := svc.publicationRepo.GetByID(publicationID)
|
||||
if err != nil {
|
||||
return model.Publication{}, fmt.Errorf("service: failed to get publication: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (svc Publication) Create(title string, ownerUserID ...uuid.UUID) (model.Publication, error) {
|
||||
log := svc.log.With(slog.String("title", title))
|
||||
log.Info("Creating publication")
|
||||
defer log.Info("Finished creating publication")
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return model.Publication{}, fmt.Errorf("service: failed to generate id: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
p := model.Publication{
|
||||
ID: id,
|
||||
Title: title,
|
||||
DateCreated: now,
|
||||
DateUpdated: now,
|
||||
}
|
||||
|
||||
err = svc.publicationRepo.Create(p)
|
||||
if err != nil {
|
||||
return model.Publication{}, fmt.Errorf("service: failed to create publication: %w", err)
|
||||
}
|
||||
|
||||
if len(ownerUserID) > 0 {
|
||||
err := svc.SetAuthor(p.ID, ownerUserID[0])
|
||||
if err != nil {
|
||||
return model.Publication{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (svc Publication) SetAuthor(publicationID uuid.UUID, userID uuid.UUID) error {
|
||||
log := svc.log.With(slog.String("publication", publicationID.String()), slog.String("user", userID.String()))
|
||||
log.Info("Setting publication owner")
|
||||
defer log.Info("Finished setting publication owner")
|
||||
|
||||
if _, err := svc.permissionRepo.GetByID(publicationID, userID); err == nil {
|
||||
err := svc.permissionRepo.Update(publicationID, userID, model.PermissionAuthor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service: failed to update publication author: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
p := model.PermissionAuthor
|
||||
|
||||
err := svc.permissionRepo.Create(publicationID, userID, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service: failed to set publication owner: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc Publication) ListOwnedBy(userID uuid.UUID) ([]model.Publication, error) {
|
||||
perms, err := svc.permissionRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service: failed to get user permissions: %w", err)
|
||||
}
|
||||
|
||||
ids := []uuid.UUID{}
|
||||
for publication, permissions := range perms {
|
||||
if permissions.Has(model.PermissionRead) {
|
||||
ids = append(ids, publication)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []model.Publication{}, nil
|
||||
}
|
||||
|
||||
publications, err := svc.publicationRepo.GetByIDs(ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service: failed to get publications: %w", err)
|
||||
}
|
||||
|
||||
return publications, nil
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package service
|
||||
|
||||
import "code.capytal.cc/capytal/comicverse/repository"
|
||||
|
||||
var ErrNotFound = repository.ErrNotFound
|
||||
188
service/token.go
188
service/token.go
@@ -1,188 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/model"
|
||||
"code.capytal.cc/capytal/comicverse/repository"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
publicKey ed25519.PublicKey
|
||||
|
||||
repo *repository.Token
|
||||
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func NewToken(cfg TokenConfig) *Token {
|
||||
cfg.Assertions.NotZero(cfg.PrivateKey)
|
||||
cfg.Assertions.NotZero(cfg.PublicKey)
|
||||
cfg.Assertions.NotZero(cfg.Repository)
|
||||
cfg.Assertions.NotZero(cfg.Logger)
|
||||
|
||||
return &Token{
|
||||
privateKey: cfg.PrivateKey,
|
||||
publicKey: cfg.PublicKey,
|
||||
repo: cfg.Repository,
|
||||
log: cfg.Logger,
|
||||
assert: cfg.Assertions,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenConfig struct {
|
||||
PrivateKey ed25519.PrivateKey
|
||||
PublicKey ed25519.PublicKey
|
||||
Repository *repository.Token
|
||||
Logger *slog.Logger
|
||||
Assertions tinyssert.Assertions
|
||||
}
|
||||
|
||||
func (svc *Token) Issue(user model.User) (string, error) { // TODO: Return a refresh token
|
||||
svc.assert.NotNil(svc.privateKey)
|
||||
svc.assert.NotNil(svc.log)
|
||||
svc.assert.NotZero(user)
|
||||
|
||||
log := svc.log.With(slog.String("user_id", user.ID.String()))
|
||||
log.Info("Issuing new token")
|
||||
defer log.Info("Finished issuing token")
|
||||
|
||||
jti, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service: failed to generate token UUID: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
expires := now.Add(30 * 24 * time.Hour) // TODO: Make the JWT short lived and use refresh tokens to create new JWTs
|
||||
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.RegisteredClaims{
|
||||
Issuer: "comicverse", // TODO: Make application ID and Name be a parameter
|
||||
Subject: user.ID.String(),
|
||||
Audience: jwt.ClaimStrings{"comicverse"}, // TODO: When we have third-party apps integration, this should be the name/URI/id of the app
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ID: jti.String(),
|
||||
})
|
||||
|
||||
signed, err := t.SignedString(svc.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service: failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Store refresh tokens in repo
|
||||
err = svc.repo.Create(model.Token{
|
||||
ID: jti,
|
||||
UserID: user.ID,
|
||||
DateCreated: now,
|
||||
DateExpires: expires,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service: failed to save token: %w", err)
|
||||
}
|
||||
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func (svc Token) Parse(tokenStr string) (*jwt.Token, error) {
|
||||
svc.assert.NotNil(svc.publicKey)
|
||||
svc.assert.NotNil(svc.log)
|
||||
|
||||
log := svc.log.With(slog.String("preview_token", tokenStr[0:5]))
|
||||
log.Info("Parsing token")
|
||||
defer log.Info("Finished parsing token")
|
||||
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||
return svc.publicKey, nil
|
||||
}, jwt.WithValidMethods([]string{(&jwt.SigningMethodEd25519{}).Alg()}))
|
||||
if err != nil {
|
||||
log.Error("Invalid token", slog.String("error", err.Error()))
|
||||
return nil, fmt.Errorf("service: invalid token: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Check issuer and if the token was issued at the correct date
|
||||
// TODO: Structure token claims type
|
||||
_, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
log.Error("Invalid claims type", slog.String("claims", fmt.Sprintf("%#v", token.Claims)))
|
||||
return nil, fmt.Errorf("service: invalid claims type")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (svc Token) Revoke(token *jwt.Token) error {
|
||||
svc.assert.NotNil(svc.log)
|
||||
svc.assert.NotNil(svc.repo)
|
||||
svc.assert.NotNil(token)
|
||||
|
||||
claims, ok := token.Claims.(jwt.RegisteredClaims)
|
||||
if !ok {
|
||||
return errors.New("service: invalid claims type")
|
||||
}
|
||||
|
||||
log := svc.log.With(slog.String("token_id", claims.ID))
|
||||
log.Info("Revoking token")
|
||||
defer log.Info("Finished revoking token")
|
||||
|
||||
jti, err := uuid.Parse(claims.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service: invalid token UUID: %w", err)
|
||||
}
|
||||
|
||||
user, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service: invalid token subject UUID: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Mark tokens as revoked instead of deleting them
|
||||
err = svc.repo.Delete(jti, user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service: failed to delete token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc Token) IsRevoked(token *jwt.Token) (bool, error) {
|
||||
svc.assert.NotNil(svc.log)
|
||||
svc.assert.NotNil(svc.repo)
|
||||
svc.assert.NotNil(token)
|
||||
|
||||
claims, ok := token.Claims.(jwt.RegisteredClaims)
|
||||
if !ok {
|
||||
return false, errors.New("service: invalid claims type")
|
||||
}
|
||||
|
||||
log := svc.log.With(slog.String("token_id", claims.ID))
|
||||
log.Info("Checking if token is revoked")
|
||||
defer log.Info("Finished checking if token is revoked")
|
||||
|
||||
jti, err := uuid.Parse(claims.ID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("service: invalid token UUID: %w", err)
|
||||
}
|
||||
|
||||
user, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("service: invalid token subject UUID: %w", err)
|
||||
}
|
||||
|
||||
_, err = svc.repo.Get(jti, user)
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return true, nil
|
||||
} else if err != nil {
|
||||
return false, fmt.Errorf("service: failed to get token: %w", err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/model"
|
||||
"code.capytal.cc/capytal/comicverse/repository"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
repo *repository.User
|
||||
|
||||
assert tinyssert.Assertions
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewUser(repo *repository.User, logger *slog.Logger, assert tinyssert.Assertions) *User {
|
||||
assert.NotNil(repo)
|
||||
assert.NotNil(logger)
|
||||
|
||||
return &User{repo: repo, assert: assert, log: logger}
|
||||
}
|
||||
|
||||
func (svc *User) Register(username, password string) (model.User, error) {
|
||||
svc.assert.NotNil(svc.repo)
|
||||
svc.assert.NotNil(svc.log)
|
||||
|
||||
log := svc.log.With(slog.String("username", username))
|
||||
log.Info("Registering user")
|
||||
defer log.Info("Finished registering user")
|
||||
|
||||
if _, err := svc.repo.GetByUsername(username); err == nil {
|
||||
return model.User{}, ErrUsernameAlreadyExists
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return model.User{}, errors.New("service: unable to generate password hash")
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return model.User{}, fmt.Errorf("service: unable to create user id", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
u := model.User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Password: hash,
|
||||
DateCreated: now,
|
||||
DateUpdated: now,
|
||||
}
|
||||
|
||||
u, err = svc.repo.Create(u)
|
||||
if err != nil {
|
||||
return model.User{}, fmt.Errorf("service: failed to create user model: %w", err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (svc *User) Login(username, password string) (user model.User, err error) {
|
||||
svc.assert.NotNil(svc.repo)
|
||||
svc.assert.NotNil(svc.log)
|
||||
|
||||
log := svc.log.With(slog.String("username", username))
|
||||
log.Info("Logging in user")
|
||||
defer log.Info("Finished logging in user")
|
||||
|
||||
user, err = svc.repo.GetByUsername(username)
|
||||
if err != nil {
|
||||
return model.User{}, fmt.Errorf("service: unable to find user: %w", err)
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(user.Password, []byte(password))
|
||||
if err != nil {
|
||||
return model.User{}, fmt.Errorf("service: unable to compare passwords: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUsernameAlreadyExists = errors.New("service: username already exists")
|
||||
ErrPasswordTooLong = bcrypt.ErrPasswordTooLong
|
||||
ErrIncorrectPassword = bcrypt.ErrMismatchedHashAndPassword
|
||||
)
|
||||
Submodule smalltrip deleted from e3813daa80
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
src/app.html
Normal file
12
src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
3
src/lib/index.ts
Normal file
3
src/lib/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as s3 } from './s3';
|
||||
export { default as db } from './sqlite';
|
||||
export * from './sqlite'
|
||||
19
src/lib/s3.ts
Normal file
19
src/lib/s3.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
AWS_ENDPOINT_URL,
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_DEFAULT_REGION,
|
||||
AWS_SECRET_ACCESS_KEY
|
||||
} from '$env/static/private';
|
||||
|
||||
import * as Minio from 'minio';
|
||||
|
||||
const client = new Minio.Client({
|
||||
endPoint: AWS_ENDPOINT_URL.split(':')[0],
|
||||
port: Number(AWS_ENDPOINT_URL.split(':')[1]),
|
||||
useSSL: false,
|
||||
region: AWS_DEFAULT_REGION,
|
||||
accessKey: AWS_ACCESS_KEY_ID,
|
||||
secretKey: AWS_SECRET_ACCESS_KEY
|
||||
});
|
||||
|
||||
export default client;
|
||||
37
src/lib/sqlite.ts
Normal file
37
src/lib/sqlite.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
|
||||
const db = await open({
|
||||
filename: 'data.db',
|
||||
driver: sqlite3.cached.Database
|
||||
});
|
||||
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
ID text NOT NULL,
|
||||
Name text NOT NULL,
|
||||
PRIMARY KEY(ID)
|
||||
)
|
||||
`);
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
title: string;
|
||||
pages: Page[];
|
||||
};
|
||||
|
||||
type Page = {
|
||||
title: string;
|
||||
src: string;
|
||||
background: string;
|
||||
iteraction: Iteraction[];
|
||||
};
|
||||
|
||||
type Iteraction = {
|
||||
x: number;
|
||||
y: number;
|
||||
link: string;
|
||||
};
|
||||
|
||||
export type { Project, Iteraction, Page };
|
||||
export default db;
|
||||
21
src/routes/+layout.svelte
Normal file
21
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<svelte:head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.colors.min.css"
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
40
src/routes/+page.server.ts
Normal file
40
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
import { db, s3, type Project } from '$lib';
|
||||
import { AWS_S3_DEFAULT_BUCKET } from '$env/static/private';
|
||||
|
||||
export const load = (async ({}) => {
|
||||
const res = await db.all<Project[]>('SELECT ID, Name FROM projects');
|
||||
return { projects: res };
|
||||
}) as PageServerLoad;
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const name = data.get('project-name');
|
||||
|
||||
if (!name) return fail(400, { name, missing: true });
|
||||
|
||||
const uuid = crypto.randomUUID().split('-')[0];
|
||||
|
||||
const res = await db.run('INSERT OR IGNORE INTO projects (ID, Name) VALUES (:id, :name)', {
|
||||
':id': uuid,
|
||||
':name': name
|
||||
});
|
||||
|
||||
const project: Project = {
|
||||
id: uuid,
|
||||
title: name.toString(),
|
||||
pages: []
|
||||
};
|
||||
|
||||
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${uuid}/project.json`, JSON.stringify(project));
|
||||
|
||||
if (res.changes == undefined) {
|
||||
return fail(500, { reason: 'Failed to insert project into database' });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
46
src/routes/+page.svelte
Normal file
46
src/routes/+page.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { Project } from '$lib';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
if ((data.projects.length + 1) % 3 !== 0) {
|
||||
data.projects.push({ Name: '', ID: '' });
|
||||
data.projects.push({ Name: '', ID: '' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
{#each data.projects as p}
|
||||
<article>
|
||||
<h1><a data-sveltekit-reload href={`/projects/${p.ID}`}>{p.Name}</a></h1>
|
||||
<p class="id">{p.ID}</p>
|
||||
</article>
|
||||
{/each}
|
||||
<article>
|
||||
<form method="POST">
|
||||
<fieldset role="group">
|
||||
<input type="text" name="project-name" placeholder="Project Name" required />
|
||||
<input type="submit" value="Create" />
|
||||
</fieldset>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: grid;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(3, minmax(0%, 1fr));
|
||||
}
|
||||
grid-column-gap: var(--pico-grid-column-gap);
|
||||
grid-row-gap: var(--pico-grid-row-gap);
|
||||
padding: 1rem var(--pico-grid-row-gap);
|
||||
}
|
||||
|
||||
.id {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
41
src/routes/files/[project]/[file]/+server.ts
Normal file
41
src/routes/files/[project]/[file]/+server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { type RequestHandler } from '@sveltejs/kit';
|
||||
import stream from 'node:stream/promises';
|
||||
|
||||
import { db, s3, type Project } from '$lib';
|
||||
import { AWS_S3_DEFAULT_BUCKET } from '$env/static/private';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
export const GET = (async ({ params }) => {
|
||||
const file = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.project}/${params.file}`);
|
||||
file.on('error', (err: any) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
let chunks: Buffer[] = [];
|
||||
let buf;
|
||||
|
||||
file.on('data', (chunk) => {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
});
|
||||
file.on('end', () => {
|
||||
buf = Buffer.concat(chunks);
|
||||
});
|
||||
await stream.finished(file)
|
||||
|
||||
let res = new Response(buf);
|
||||
res.headers.set(
|
||||
'Content-Type',
|
||||
(() => {
|
||||
switch (extname(params.file!)) {
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.json':
|
||||
return 'application/json';
|
||||
}
|
||||
return 'text/plain';
|
||||
})()
|
||||
);
|
||||
res.headers.set('Cache-Control', 'max-age=604800')
|
||||
|
||||
return res;
|
||||
}) as RequestHandler;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user